Initial commit
This commit is contained in:
841
skills/rack-middleware/SKILL.md
Normal file
841
skills/rack-middleware/SKILL.md
Normal file
@@ -0,0 +1,841 @@
|
||||
---
|
||||
name: rack-middleware
|
||||
description: Rack middleware development, configuration, and integration patterns. Use when working with middleware stacks or creating custom middleware.
|
||||
---
|
||||
|
||||
# Rack Middleware Skill
|
||||
|
||||
## Tier 1: Quick Reference - Middleware Basics
|
||||
|
||||
### Middleware Structure
|
||||
|
||||
```ruby
|
||||
class MyMiddleware
|
||||
def initialize(app, options = {})
|
||||
@app = app
|
||||
@options = options
|
||||
end
|
||||
|
||||
def call(env)
|
||||
# Before request
|
||||
# Modify env if needed
|
||||
|
||||
# Call next middleware
|
||||
status, headers, body = @app.call(env)
|
||||
|
||||
# After request
|
||||
# Modify response if needed
|
||||
|
||||
[status, headers, body]
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
use MyMiddleware, option: 'value'
|
||||
```
|
||||
|
||||
### Common Middleware
|
||||
|
||||
```ruby
|
||||
# Session management
|
||||
use Rack::Session::Cookie, secret: ENV['SESSION_SECRET']
|
||||
|
||||
# Security
|
||||
use Rack::Protection
|
||||
|
||||
# Compression
|
||||
use Rack::Deflater
|
||||
|
||||
# Logging
|
||||
use Rack::CommonLogger
|
||||
|
||||
# Static files
|
||||
use Rack::Static, urls: ['/css', '/js'], root: 'public'
|
||||
```
|
||||
|
||||
### Middleware Ordering
|
||||
|
||||
```ruby
|
||||
# config.ru - Correct order
|
||||
use Rack::Deflater # 1. Compression
|
||||
use Rack::Static # 2. Static files
|
||||
use Rack::CommonLogger # 3. Logging
|
||||
use Rack::Session::Cookie # 4. Sessions
|
||||
use Rack::Protection # 5. Security
|
||||
use CustomAuth # 6. Authentication
|
||||
run Application # 7. Application
|
||||
```
|
||||
|
||||
### Request/Response Access
|
||||
|
||||
```ruby
|
||||
class SimpleMiddleware
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
# Access request via env hash
|
||||
method = env['REQUEST_METHOD']
|
||||
path = env['PATH_INFO']
|
||||
query = env['QUERY_STRING']
|
||||
|
||||
# Or use Rack::Request
|
||||
request = Rack::Request.new(env)
|
||||
params = request.params
|
||||
|
||||
# Process request
|
||||
status, headers, body = @app.call(env)
|
||||
|
||||
# Modify response
|
||||
headers['X-Custom-Header'] = 'value'
|
||||
|
||||
[status, headers, body]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Detailed Instructions - Advanced Middleware
|
||||
|
||||
### Custom Middleware Development
|
||||
|
||||
**Request Logging Middleware:**
|
||||
```ruby
|
||||
require 'logger'
|
||||
|
||||
class RequestLogger
|
||||
def initialize(app, options = {})
|
||||
@app = app
|
||||
@logger = options[:logger] || Logger.new(STDOUT)
|
||||
@skip_paths = options[:skip_paths] || []
|
||||
end
|
||||
|
||||
def call(env)
|
||||
return @app.call(env) if skip_logging?(env)
|
||||
|
||||
start_time = Time.now
|
||||
request = Rack::Request.new(env)
|
||||
|
||||
log_request_start(request)
|
||||
|
||||
status, headers, body = @app.call(env)
|
||||
|
||||
duration = Time.now - start_time
|
||||
log_request_end(request, status, duration)
|
||||
|
||||
[status, headers, body]
|
||||
rescue StandardError => e
|
||||
log_error(request, e)
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_logging?(env)
|
||||
path = env['PATH_INFO']
|
||||
@skip_paths.any? { |skip| path.start_with?(skip) }
|
||||
end
|
||||
|
||||
def log_request_start(request)
|
||||
@logger.info({
|
||||
event: 'request.start',
|
||||
method: request.request_method,
|
||||
path: request.path,
|
||||
ip: request.ip,
|
||||
user_agent: request.user_agent
|
||||
}.to_json)
|
||||
end
|
||||
|
||||
def log_request_end(request, status, duration)
|
||||
@logger.info({
|
||||
event: 'request.end',
|
||||
method: request.request_method,
|
||||
path: request.path,
|
||||
status: status,
|
||||
duration: duration.round(3)
|
||||
}.to_json)
|
||||
end
|
||||
|
||||
def log_error(request, error)
|
||||
@logger.error({
|
||||
event: 'request.error',
|
||||
method: request.request_method,
|
||||
path: request.path,
|
||||
error: error.class.name,
|
||||
message: error.message,
|
||||
backtrace: error.backtrace[0..5]
|
||||
}.to_json)
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
use RequestLogger, skip_paths: ['/health', '/metrics']
|
||||
```
|
||||
|
||||
**Authentication Middleware:**
|
||||
```ruby
|
||||
class TokenAuthentication
|
||||
def initialize(app, options = {})
|
||||
@app = app
|
||||
@token_header = options[:header] || 'HTTP_AUTHORIZATION'
|
||||
@skip_paths = options[:skip_paths] || []
|
||||
@realm = options[:realm] || 'Application'
|
||||
end
|
||||
|
||||
def call(env)
|
||||
return @app.call(env) if skip_authentication?(env)
|
||||
|
||||
token = extract_token(env)
|
||||
|
||||
if valid_token?(token)
|
||||
user = find_user_by_token(token)
|
||||
env['current_user'] = user
|
||||
@app.call(env)
|
||||
else
|
||||
unauthorized_response
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_authentication?(env)
|
||||
path = env['PATH_INFO']
|
||||
method = env['REQUEST_METHOD']
|
||||
|
||||
# Skip for public paths
|
||||
@skip_paths.any? { |skip| path.start_with?(skip) } ||
|
||||
# Skip for OPTIONS (CORS preflight)
|
||||
method == 'OPTIONS'
|
||||
end
|
||||
|
||||
def extract_token(env)
|
||||
auth_header = env[@token_header]
|
||||
return nil unless auth_header
|
||||
|
||||
# Support "Bearer TOKEN" format
|
||||
if auth_header.start_with?('Bearer ')
|
||||
auth_header.split(' ', 2).last
|
||||
else
|
||||
auth_header
|
||||
end
|
||||
end
|
||||
|
||||
def valid_token?(token)
|
||||
return false unless token
|
||||
|
||||
# Implement your token validation logic
|
||||
# This is a placeholder
|
||||
token.length >= 32
|
||||
end
|
||||
|
||||
def find_user_by_token(token)
|
||||
# Implement your user lookup logic
|
||||
# This is a placeholder
|
||||
{ id: 1, email: 'user@example.com' }
|
||||
end
|
||||
|
||||
def unauthorized_response
|
||||
[
|
||||
401,
|
||||
{
|
||||
'Content-Type' => 'application/json',
|
||||
'WWW-Authenticate' => "Bearer realm=\"#{@realm}\""
|
||||
},
|
||||
['{"error": "Unauthorized"}']
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
use TokenAuthentication,
|
||||
skip_paths: ['/login', '/register', '/public']
|
||||
```
|
||||
|
||||
**Caching Middleware:**
|
||||
```ruby
|
||||
require 'digest/md5'
|
||||
|
||||
class SimpleCache
|
||||
def initialize(app, options = {})
|
||||
@app = app
|
||||
@cache = {}
|
||||
@ttl = options[:ttl] || 300 # 5 minutes
|
||||
@cache_methods = options[:methods] || ['GET']
|
||||
end
|
||||
|
||||
def call(env)
|
||||
request = Rack::Request.new(env)
|
||||
|
||||
return @app.call(env) unless cacheable?(request)
|
||||
|
||||
cache_key = generate_cache_key(env)
|
||||
|
||||
if cached_response = get_from_cache(cache_key)
|
||||
return cached_response
|
||||
end
|
||||
|
||||
status, headers, body = @app.call(env)
|
||||
|
||||
if cacheable_response?(status)
|
||||
cache_response(cache_key, [status, headers, body])
|
||||
end
|
||||
|
||||
[status, headers, body]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cacheable?(request)
|
||||
@cache_methods.include?(request.request_method)
|
||||
end
|
||||
|
||||
def cacheable_response?(status)
|
||||
status == 200
|
||||
end
|
||||
|
||||
def generate_cache_key(env)
|
||||
# Include method, path, and query string
|
||||
Digest::MD5.hexdigest([
|
||||
env['REQUEST_METHOD'],
|
||||
env['PATH_INFO'],
|
||||
env['QUERY_STRING']
|
||||
].join('|'))
|
||||
end
|
||||
|
||||
def get_from_cache(key)
|
||||
entry = @cache[key]
|
||||
return nil unless entry
|
||||
|
||||
# Check if cache entry is still valid
|
||||
if Time.now - entry[:cached_at] <= @ttl
|
||||
entry[:response]
|
||||
else
|
||||
@cache.delete(key)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def cache_response(key, response)
|
||||
@cache[key] = {
|
||||
response: response,
|
||||
cached_at: Time.now
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Usage with Redis for distributed caching
|
||||
class RedisCache
|
||||
def initialize(app, options = {})
|
||||
@app = app
|
||||
@redis = Redis.new(url: options[:redis_url])
|
||||
@ttl = options[:ttl] || 300
|
||||
@namespace = options[:namespace] || 'cache'
|
||||
end
|
||||
|
||||
def call(env)
|
||||
request = Rack::Request.new(env)
|
||||
|
||||
return @app.call(env) unless request.get?
|
||||
|
||||
cache_key = generate_cache_key(env)
|
||||
|
||||
if cached = @redis.get(cache_key)
|
||||
return Marshal.load(cached)
|
||||
end
|
||||
|
||||
status, headers, body = @app.call(env)
|
||||
|
||||
if status == 200
|
||||
@redis.setex(cache_key, @ttl, Marshal.dump([status, headers, body]))
|
||||
end
|
||||
|
||||
[status, headers, body]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_cache_key(env)
|
||||
"#{@namespace}:#{Digest::MD5.hexdigest(env['PATH_INFO'] + env['QUERY_STRING'])}"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Request Transformation Middleware:**
|
||||
```ruby
|
||||
class JSONBodyParser
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
if json_request?(env)
|
||||
body = env['rack.input'].read
|
||||
env['rack.input'].rewind
|
||||
|
||||
begin
|
||||
parsed = JSON.parse(body)
|
||||
env['rack.request.form_hash'] = parsed
|
||||
env['parsed_json'] = parsed
|
||||
rescue JSON::ParserError => e
|
||||
return error_response('Invalid JSON', 400)
|
||||
end
|
||||
end
|
||||
|
||||
@app.call(env)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def json_request?(env)
|
||||
content_type = env['CONTENT_TYPE']
|
||||
content_type && content_type.include?('application/json')
|
||||
end
|
||||
|
||||
def error_response(message, status)
|
||||
[
|
||||
status,
|
||||
{ 'Content-Type' => 'application/json' },
|
||||
[{ error: message }.to_json]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
# XML Parser
|
||||
class XMLBodyParser
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
if xml_request?(env)
|
||||
body = env['rack.input'].read
|
||||
env['rack.input'].rewind
|
||||
|
||||
begin
|
||||
parsed = Hash.from_xml(body)
|
||||
env['rack.request.form_hash'] = parsed
|
||||
env['parsed_xml'] = parsed
|
||||
rescue StandardError => e
|
||||
return error_response('Invalid XML', 400)
|
||||
end
|
||||
end
|
||||
|
||||
@app.call(env)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def xml_request?(env)
|
||||
content_type = env['CONTENT_TYPE']
|
||||
content_type && (content_type.include?('application/xml') ||
|
||||
content_type.include?('text/xml'))
|
||||
end
|
||||
|
||||
def error_response(message, status)
|
||||
[
|
||||
status,
|
||||
{ 'Content-Type' => 'application/json' },
|
||||
[{ error: message }.to_json]
|
||||
]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Middleware Ordering Patterns
|
||||
|
||||
**Security-First Stack:**
|
||||
```ruby
|
||||
# config.ru
|
||||
# 1. SSL redirect (production only)
|
||||
use Rack::SSL if ENV['RACK_ENV'] == 'production'
|
||||
|
||||
# 2. Rate limiting (before everything else)
|
||||
use Rack::Attack
|
||||
|
||||
# 3. Security headers
|
||||
use SecurityHeaders
|
||||
|
||||
# 4. CORS (for API applications)
|
||||
use Rack::Cors do
|
||||
allow do
|
||||
origins '*'
|
||||
resource '*', headers: :any, methods: [:get, :post, :put, :delete, :options]
|
||||
end
|
||||
end
|
||||
|
||||
# 5. Compression
|
||||
use Rack::Deflater
|
||||
|
||||
# 6. Static files
|
||||
use Rack::Static, urls: ['/public'], root: 'public'
|
||||
|
||||
# 7. Logging
|
||||
use Rack::CommonLogger
|
||||
|
||||
# 8. Request parsing
|
||||
use JSONBodyParser
|
||||
|
||||
# 9. Sessions
|
||||
use Rack::Session::Cookie,
|
||||
secret: ENV['SESSION_SECRET'],
|
||||
same_site: :strict,
|
||||
httponly: true,
|
||||
secure: ENV['RACK_ENV'] == 'production'
|
||||
|
||||
# 10. Protection (CSRF, etc.)
|
||||
use Rack::Protection
|
||||
|
||||
# 11. Authentication
|
||||
use TokenAuthentication, skip_paths: ['/login', '/public']
|
||||
|
||||
# 12. Performance monitoring
|
||||
use PerformanceMonitor
|
||||
|
||||
# 13. Application
|
||||
run Application
|
||||
```
|
||||
|
||||
**API-Focused Stack:**
|
||||
```ruby
|
||||
# config.ru for API
|
||||
# 1. CORS first for preflight
|
||||
use Rack::Cors do
|
||||
allow do
|
||||
origins ENV.fetch('ALLOWED_ORIGINS', '*').split(',')
|
||||
resource '*',
|
||||
headers: :any,
|
||||
methods: [:get, :post, :put, :patch, :delete, :options],
|
||||
credentials: true,
|
||||
max_age: 86400
|
||||
end
|
||||
end
|
||||
|
||||
# 2. Rate limiting
|
||||
use Rack::Attack
|
||||
|
||||
# 3. Compression
|
||||
use Rack::Deflater
|
||||
|
||||
# 4. Logging (structured JSON logs)
|
||||
use RequestLogger
|
||||
|
||||
# 5. Request parsing
|
||||
use JSONBodyParser
|
||||
|
||||
# 6. Authentication
|
||||
use TokenAuthentication, skip_paths: ['/auth']
|
||||
|
||||
# 7. Caching
|
||||
use RedisCache, ttl: 300
|
||||
|
||||
# 8. Application
|
||||
run API
|
||||
```
|
||||
|
||||
### Conditional Middleware
|
||||
|
||||
**Environment-Based:**
|
||||
```ruby
|
||||
class ConditionalMiddleware
|
||||
def initialize(app, condition, middleware, *args)
|
||||
@app = if condition.call
|
||||
middleware.new(app, *args)
|
||||
else
|
||||
app
|
||||
end
|
||||
end
|
||||
|
||||
def call(env)
|
||||
@app.call(env)
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
use ConditionalMiddleware,
|
||||
-> { ENV['RACK_ENV'] == 'development' },
|
||||
Rack::ShowExceptions
|
||||
|
||||
use ConditionalMiddleware,
|
||||
-> { ENV['ENABLE_PROFILING'] == 'true' },
|
||||
RackMiniProfiler
|
||||
```
|
||||
|
||||
**Path-Based:**
|
||||
```ruby
|
||||
class PathBasedMiddleware
|
||||
def initialize(app, pattern, middleware, *args)
|
||||
@app = app
|
||||
@pattern = pattern
|
||||
@middleware = middleware.new(app, *args)
|
||||
end
|
||||
|
||||
def call(env)
|
||||
if env['PATH_INFO'].match?(@pattern)
|
||||
@middleware.call(env)
|
||||
else
|
||||
@app.call(env)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
use PathBasedMiddleware, %r{^/api}, CacheMiddleware, ttl: 300
|
||||
use PathBasedMiddleware, %r{^/admin}, AdminAuth
|
||||
```
|
||||
|
||||
### Error Handling Middleware
|
||||
|
||||
```ruby
|
||||
class ErrorHandler
|
||||
def initialize(app, options = {})
|
||||
@app = app
|
||||
@logger = options[:logger] || Logger.new(STDOUT)
|
||||
@error_handlers = options[:handlers] || {}
|
||||
end
|
||||
|
||||
def call(env)
|
||||
@app.call(env)
|
||||
rescue StandardError => e
|
||||
handle_error(env, e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_error(env, error)
|
||||
request = Rack::Request.new(env)
|
||||
|
||||
# Log error
|
||||
@logger.error({
|
||||
error: error.class.name,
|
||||
message: error.message,
|
||||
path: request.path,
|
||||
method: request.request_method,
|
||||
backtrace: error.backtrace[0..10]
|
||||
}.to_json)
|
||||
|
||||
# Custom handler for specific error types
|
||||
if handler = @error_handlers[error.class]
|
||||
return handler.call(error)
|
||||
end
|
||||
|
||||
# Default error response
|
||||
status = status_for_error(error)
|
||||
[
|
||||
status,
|
||||
{ 'Content-Type' => 'application/json' },
|
||||
[{ error: error.message, type: error.class.name }.to_json]
|
||||
]
|
||||
end
|
||||
|
||||
def status_for_error(error)
|
||||
case error
|
||||
when ArgumentError, ValidationError
|
||||
400
|
||||
when NotFoundError
|
||||
404
|
||||
when AuthorizationError
|
||||
403
|
||||
when AuthenticationError
|
||||
401
|
||||
else
|
||||
500
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
use ErrorHandler,
|
||||
handlers: {
|
||||
ValidationError => ->(e) {
|
||||
[422, { 'Content-Type' => 'application/json' },
|
||||
[{ error: e.message, details: e.details }.to_json]]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Resources & Examples
|
||||
|
||||
### Complete Middleware Examples
|
||||
|
||||
**Performance Monitoring:**
|
||||
```ruby
|
||||
class PerformanceMonitor
|
||||
def initialize(app, options = {})
|
||||
@app = app
|
||||
@threshold = options[:threshold] || 1.0 # 1 second
|
||||
@logger = options[:logger] || Logger.new(STDOUT)
|
||||
end
|
||||
|
||||
def call(env)
|
||||
start_time = Time.now
|
||||
memory_before = memory_usage
|
||||
|
||||
status, headers, body = @app.call(env)
|
||||
|
||||
duration = Time.now - start_time
|
||||
memory_after = memory_usage
|
||||
memory_delta = memory_after - memory_before
|
||||
|
||||
# Add performance headers
|
||||
headers['X-Runtime'] = duration.to_s
|
||||
headers['X-Memory-Delta'] = memory_delta.to_s
|
||||
|
||||
# Log slow requests
|
||||
if duration > @threshold
|
||||
log_slow_request(env, duration, memory_delta)
|
||||
end
|
||||
|
||||
[status, headers, body]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def memory_usage
|
||||
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0 # MB
|
||||
end
|
||||
|
||||
def log_slow_request(env, duration, memory)
|
||||
@logger.warn({
|
||||
event: 'slow_request',
|
||||
method: env['REQUEST_METHOD'],
|
||||
path: env['PATH_INFO'],
|
||||
duration: duration.round(3),
|
||||
memory_delta: memory.round(2)
|
||||
}.to_json)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Request ID Tracking:**
|
||||
```ruby
|
||||
class RequestID
|
||||
def initialize(app, options = {})
|
||||
@app = app
|
||||
@header = options[:header] || 'X-Request-ID'
|
||||
end
|
||||
|
||||
def call(env)
|
||||
request_id = env["HTTP_#{@header.upcase.tr('-', '_')}"] || generate_id
|
||||
env['request.id'] = request_id
|
||||
|
||||
status, headers, body = @app.call(env)
|
||||
|
||||
headers[@header] = request_id
|
||||
|
||||
[status, headers, body]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_id
|
||||
SecureRandom.uuid
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Response Modification:**
|
||||
```ruby
|
||||
class ResponseTransformer
|
||||
def initialize(app, &block)
|
||||
@app = app
|
||||
@transformer = block
|
||||
end
|
||||
|
||||
def call(env)
|
||||
status, headers, body = @app.call(env)
|
||||
|
||||
if should_transform?(headers)
|
||||
body = transform_body(body)
|
||||
end
|
||||
|
||||
[status, headers, body]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_transform?(headers)
|
||||
headers['Content-Type']&.include?('application/json')
|
||||
end
|
||||
|
||||
def transform_body(body)
|
||||
content = body.is_a?(Array) ? body.join : body.read
|
||||
transformed = @transformer.call(content)
|
||||
[transformed]
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
use ResponseTransformer do |body|
|
||||
data = JSON.parse(body)
|
||||
data['timestamp'] = Time.now.to_i
|
||||
data.to_json
|
||||
end
|
||||
```
|
||||
|
||||
### Testing Middleware
|
||||
|
||||
```ruby
|
||||
RSpec.describe RequestLogger do
|
||||
let(:app) { ->(env) { [200, {}, ['OK']] } }
|
||||
let(:logger) { double('Logger', info: nil, error: nil) }
|
||||
let(:middleware) { RequestLogger.new(app, logger: logger) }
|
||||
let(:request) { Rack::MockRequest.new(middleware) }
|
||||
|
||||
describe 'request logging' do
|
||||
it 'logs request start' do
|
||||
expect(logger).to receive(:info).with(hash_including(event: 'request.start'))
|
||||
request.get('/')
|
||||
end
|
||||
|
||||
it 'logs request end with duration' do
|
||||
expect(logger).to receive(:info).with(hash_including(
|
||||
event: 'request.end',
|
||||
duration: kind_of(Numeric)
|
||||
))
|
||||
request.get('/')
|
||||
end
|
||||
|
||||
it 'includes request details' do
|
||||
expect(logger).to receive(:info).with(hash_including(
|
||||
method: 'GET',
|
||||
path: '/test'
|
||||
))
|
||||
request.get('/test')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'error logging' do
|
||||
let(:app) { ->(env) { raise StandardError, 'Test error' } }
|
||||
|
||||
it 'logs errors' do
|
||||
expect(logger).to receive(:error).with(hash_including(
|
||||
event: 'request.error',
|
||||
error: 'StandardError'
|
||||
))
|
||||
|
||||
expect { request.get('/') }.to raise_error(StandardError)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'skip paths' do
|
||||
let(:middleware) { RequestLogger.new(app, logger: logger, skip_paths: ['/health']) }
|
||||
|
||||
it 'skips logging for configured paths' do
|
||||
expect(logger).not_to receive(:info)
|
||||
request.get('/health')
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Additional Resources
|
||||
|
||||
- **Middleware Template:** `assets/middleware-template.rb` - Boilerplate for new middleware
|
||||
- **Middleware Examples:** `assets/middleware-examples/` - Collection of useful middleware
|
||||
- **Configuration Guide:** `assets/configuration-guide.md` - Best practices for middleware configuration
|
||||
- **Performance Guide:** `references/performance-optimization.md` - Optimizing middleware performance
|
||||
- **Testing Guide:** `references/middleware-testing.md` - Comprehensive testing strategies
|
||||
854
skills/ruby-patterns/SKILL.md
Normal file
854
skills/ruby-patterns/SKILL.md
Normal file
@@ -0,0 +1,854 @@
|
||||
---
|
||||
name: ruby-patterns
|
||||
description: Modern Ruby idioms, design patterns, metaprogramming techniques, and best practices. Use when writing Ruby code or refactoring for clarity.
|
||||
---
|
||||
|
||||
# Ruby Patterns Skill
|
||||
|
||||
## Tier 1: Quick Reference - Common Idioms
|
||||
|
||||
### Conditional Assignment
|
||||
|
||||
```ruby
|
||||
# Set if nil
|
||||
value ||= default_value
|
||||
|
||||
# Set if falsy (nil or false)
|
||||
value = value || default_value
|
||||
|
||||
# Safe navigation
|
||||
user&.profile&.avatar&.url
|
||||
```
|
||||
|
||||
### Array and Hash Shortcuts
|
||||
|
||||
```ruby
|
||||
# Array creation
|
||||
%w[apple banana orange] # ["apple", "banana", "orange"]
|
||||
%i[name email age] # [:name, :email, :age]
|
||||
|
||||
# Hash creation
|
||||
{ name: 'John', age: 30 } # Symbol keys
|
||||
{ 'name' => 'John' } # String keys
|
||||
|
||||
# Hash access with default
|
||||
hash.fetch(:key, default)
|
||||
hash[:key] || default
|
||||
```
|
||||
|
||||
### Enumerable Shortcuts
|
||||
|
||||
```ruby
|
||||
# Transformation
|
||||
array.map(&:upcase)
|
||||
array.select(&:active?)
|
||||
array.reject(&:empty?)
|
||||
|
||||
# Aggregation
|
||||
array.sum
|
||||
array.max
|
||||
array.min
|
||||
numbers.reduce(:+)
|
||||
|
||||
# Finding
|
||||
array.find(&:valid?)
|
||||
array.any?(&:present?)
|
||||
array.all?(&:valid?)
|
||||
```
|
||||
|
||||
### String Operations
|
||||
|
||||
```ruby
|
||||
# Interpolation
|
||||
"Hello #{name}!"
|
||||
|
||||
# Safe interpolation
|
||||
"Result: %{value}" % { value: result }
|
||||
|
||||
# Multiline
|
||||
<<~TEXT
|
||||
Heredoc with indentation
|
||||
removed automatically
|
||||
TEXT
|
||||
```
|
||||
|
||||
### Block Syntax
|
||||
|
||||
```ruby
|
||||
# Single line - use braces
|
||||
array.map { |x| x * 2 }
|
||||
|
||||
# Multi-line - use do/end
|
||||
array.each do |item|
|
||||
process(item)
|
||||
log(item)
|
||||
end
|
||||
|
||||
# Symbol to_proc
|
||||
array.map(&:to_s)
|
||||
array.select(&:even?)
|
||||
```
|
||||
|
||||
### Guard Clauses
|
||||
|
||||
```ruby
|
||||
def process(user)
|
||||
return unless user
|
||||
return unless user.active?
|
||||
|
||||
# Main logic here
|
||||
end
|
||||
```
|
||||
|
||||
### Case Statements
|
||||
|
||||
```ruby
|
||||
# Traditional
|
||||
case status
|
||||
when 'active'
|
||||
activate
|
||||
when 'inactive'
|
||||
deactivate
|
||||
end
|
||||
|
||||
# With ranges
|
||||
case age
|
||||
when 0..17
|
||||
'minor'
|
||||
when 18..64
|
||||
'adult'
|
||||
else
|
||||
'senior'
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Detailed Instructions - Design Patterns
|
||||
|
||||
### Creational Patterns
|
||||
|
||||
**Factory Pattern:**
|
||||
```ruby
|
||||
class UserFactory
|
||||
def self.create(type, attributes)
|
||||
case type
|
||||
when :admin
|
||||
AdminUser.new(attributes)
|
||||
when :member
|
||||
MemberUser.new(attributes)
|
||||
when :guest
|
||||
GuestUser.new(attributes)
|
||||
else
|
||||
raise ArgumentError, "Unknown user type: #{type}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
user = UserFactory.create(:admin, name: 'John', email: 'john@example.com')
|
||||
```
|
||||
|
||||
**Builder Pattern:**
|
||||
```ruby
|
||||
class QueryBuilder
|
||||
def initialize
|
||||
@conditions = []
|
||||
@order = nil
|
||||
@limit = nil
|
||||
end
|
||||
|
||||
def where(condition)
|
||||
@conditions << condition
|
||||
self
|
||||
end
|
||||
|
||||
def order(column)
|
||||
@order = column
|
||||
self
|
||||
end
|
||||
|
||||
def limit(count)
|
||||
@limit = count
|
||||
self
|
||||
end
|
||||
|
||||
def build
|
||||
query = "SELECT * FROM users"
|
||||
query += " WHERE #{@conditions.join(' AND ')}" if @conditions.any?
|
||||
query += " ORDER BY #{@order}" if @order
|
||||
query += " LIMIT #{@limit}" if @limit
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
query = QueryBuilder.new
|
||||
.where("active = true")
|
||||
.where("age > 18")
|
||||
.order("created_at DESC")
|
||||
.limit(10)
|
||||
.build
|
||||
```
|
||||
|
||||
**Singleton Pattern:**
|
||||
```ruby
|
||||
require 'singleton'
|
||||
|
||||
class Configuration
|
||||
include Singleton
|
||||
|
||||
attr_accessor :api_key, :timeout
|
||||
|
||||
def initialize
|
||||
@api_key = ENV['API_KEY']
|
||||
@timeout = 30
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
config = Configuration.instance
|
||||
config.api_key = 'new_key'
|
||||
```
|
||||
|
||||
### Structural Patterns
|
||||
|
||||
**Decorator Pattern:**
|
||||
```ruby
|
||||
# Simple decorator
|
||||
class User
|
||||
attr_accessor :name, :email
|
||||
|
||||
def initialize(name, email)
|
||||
@name = name
|
||||
@email = email
|
||||
end
|
||||
end
|
||||
|
||||
class AdminUser < SimpleDelegator
|
||||
def permissions
|
||||
[:read, :write, :delete, :admin]
|
||||
end
|
||||
|
||||
def admin?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
user = User.new('John', 'john@example.com')
|
||||
admin = AdminUser.new(user)
|
||||
admin.name # Delegates to user
|
||||
admin.admin? # From decorator
|
||||
|
||||
# Using Ruby's Forwardable
|
||||
require 'forwardable'
|
||||
|
||||
class UserDecorator
|
||||
extend Forwardable
|
||||
def_delegators :@user, :name, :email
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def display_name
|
||||
"#{@user.name} (#{@user.email})"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Adapter Pattern:**
|
||||
```ruby
|
||||
# Adapting third-party API
|
||||
class LegacyPaymentGateway
|
||||
def make_payment(amount, card)
|
||||
# Legacy implementation
|
||||
end
|
||||
end
|
||||
|
||||
class PaymentAdapter
|
||||
def initialize(gateway)
|
||||
@gateway = gateway
|
||||
end
|
||||
|
||||
def process(amount:, card_number:)
|
||||
card = { number: card_number }
|
||||
@gateway.make_payment(amount, card)
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
legacy = LegacyPaymentGateway.new
|
||||
adapter = PaymentAdapter.new(legacy)
|
||||
adapter.process(amount: 100, card_number: '1234')
|
||||
```
|
||||
|
||||
**Composite Pattern:**
|
||||
```ruby
|
||||
class File
|
||||
attr_reader :name, :size
|
||||
|
||||
def initialize(name, size)
|
||||
@name = name
|
||||
@size = size
|
||||
end
|
||||
|
||||
def total_size
|
||||
size
|
||||
end
|
||||
end
|
||||
|
||||
class Directory
|
||||
attr_reader :name
|
||||
|
||||
def initialize(name)
|
||||
@name = name
|
||||
@contents = []
|
||||
end
|
||||
|
||||
def add(item)
|
||||
@contents << item
|
||||
end
|
||||
|
||||
def total_size
|
||||
@contents.sum(&:total_size)
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
root = Directory.new('root')
|
||||
root.add(File.new('file1.txt', 100))
|
||||
subdir = Directory.new('subdir')
|
||||
subdir.add(File.new('file2.txt', 200))
|
||||
root.add(subdir)
|
||||
root.total_size # 300
|
||||
```
|
||||
|
||||
### Behavioral Patterns
|
||||
|
||||
**Strategy Pattern:**
|
||||
```ruby
|
||||
class PaymentProcessor
|
||||
def initialize(strategy)
|
||||
@strategy = strategy
|
||||
end
|
||||
|
||||
def process(amount)
|
||||
@strategy.process(amount)
|
||||
end
|
||||
end
|
||||
|
||||
class CreditCardStrategy
|
||||
def process(amount)
|
||||
puts "Processing #{amount} via credit card"
|
||||
end
|
||||
end
|
||||
|
||||
class PayPalStrategy
|
||||
def process(amount)
|
||||
puts "Processing #{amount} via PayPal"
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
processor = PaymentProcessor.new(CreditCardStrategy.new)
|
||||
processor.process(100)
|
||||
|
||||
processor = PaymentProcessor.new(PayPalStrategy.new)
|
||||
processor.process(100)
|
||||
```
|
||||
|
||||
**Observer Pattern:**
|
||||
```ruby
|
||||
require 'observer'
|
||||
|
||||
class Order
|
||||
include Observable
|
||||
|
||||
attr_reader :status
|
||||
|
||||
def initialize
|
||||
@status = :pending
|
||||
end
|
||||
|
||||
def complete!
|
||||
@status = :completed
|
||||
changed
|
||||
notify_observers(self)
|
||||
end
|
||||
end
|
||||
|
||||
class EmailNotifier
|
||||
def update(order)
|
||||
puts "Sending email: Order #{order.object_id} is #{order.status}"
|
||||
end
|
||||
end
|
||||
|
||||
class SMSNotifier
|
||||
def update(order)
|
||||
puts "Sending SMS: Order #{order.object_id} is #{order.status}"
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
order = Order.new
|
||||
order.add_observer(EmailNotifier.new)
|
||||
order.add_observer(SMSNotifier.new)
|
||||
order.complete! # Both notifiers triggered
|
||||
```
|
||||
|
||||
**Command Pattern:**
|
||||
```ruby
|
||||
class Command
|
||||
def execute
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def undo
|
||||
raise NotImplementedError
|
||||
end
|
||||
end
|
||||
|
||||
class CreateUserCommand < Command
|
||||
def initialize(user_service, params)
|
||||
@user_service = user_service
|
||||
@params = params
|
||||
@user = nil
|
||||
end
|
||||
|
||||
def execute
|
||||
@user = @user_service.create(@params)
|
||||
end
|
||||
|
||||
def undo
|
||||
@user_service.delete(@user.id) if @user
|
||||
end
|
||||
end
|
||||
|
||||
class CommandInvoker
|
||||
def initialize
|
||||
@history = []
|
||||
end
|
||||
|
||||
def execute(command)
|
||||
command.execute
|
||||
@history << command
|
||||
end
|
||||
|
||||
def undo
|
||||
command = @history.pop
|
||||
command&.undo
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
invoker = CommandInvoker.new
|
||||
command = CreateUserCommand.new(user_service, { name: 'John' })
|
||||
invoker.execute(command)
|
||||
invoker.undo # Rolls back
|
||||
```
|
||||
|
||||
### Metaprogramming Techniques
|
||||
|
||||
**Dynamic Method Definition:**
|
||||
```ruby
|
||||
class Model
|
||||
ATTRIBUTES = [:name, :email, :age]
|
||||
|
||||
ATTRIBUTES.each do |attr|
|
||||
define_method(attr) do
|
||||
instance_variable_get("@#{attr}")
|
||||
end
|
||||
|
||||
define_method("#{attr}=") do |value|
|
||||
instance_variable_set("@#{attr}", value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
model = Model.new
|
||||
model.name = 'John'
|
||||
model.name # 'John'
|
||||
```
|
||||
|
||||
**Method Missing:**
|
||||
```ruby
|
||||
class DynamicFinder
|
||||
def initialize(data)
|
||||
@data = data
|
||||
end
|
||||
|
||||
def method_missing(method_name, *args)
|
||||
if method_name.to_s.start_with?('find_by_')
|
||||
attribute = method_name.to_s.sub('find_by_', '')
|
||||
@data.find { |item| item[attribute.to_sym] == args.first }
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def respond_to_missing?(method_name, include_private = false)
|
||||
method_name.to_s.start_with?('find_by_') || super
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
data = [
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' }
|
||||
]
|
||||
finder = DynamicFinder.new(data)
|
||||
finder.find_by_name('John') # { name: 'John', ... }
|
||||
finder.find_by_email('jane@example.com') # { name: 'Jane', ... }
|
||||
```
|
||||
|
||||
**Class Macros (DSL):**
|
||||
```ruby
|
||||
class Validator
|
||||
def self.validates(attribute, rules)
|
||||
@validations ||= []
|
||||
@validations << [attribute, rules]
|
||||
|
||||
define_method(:valid?) do
|
||||
self.class.instance_variable_get(:@validations).all? do |attr, rules|
|
||||
value = send(attr)
|
||||
validate_rules(value, rules)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_rules(value, rules)
|
||||
rules.all? do |rule, param|
|
||||
case rule
|
||||
when :presence
|
||||
!value.nil? && !value.empty?
|
||||
when :length
|
||||
value.length <= param
|
||||
when :format
|
||||
value.match?(param)
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class User < Validator
|
||||
attr_accessor :name, :email
|
||||
|
||||
validates :name, presence: true, length: 50
|
||||
validates :email, presence: true, format: /@/
|
||||
|
||||
def initialize(name, email)
|
||||
@name = name
|
||||
@email = email
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
user = User.new('John', 'john@example.com')
|
||||
user.valid? # true
|
||||
```
|
||||
|
||||
**Module Inclusion Hooks:**
|
||||
```ruby
|
||||
module Timestampable
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
attr_accessor :created_at, :updated_at
|
||||
|
||||
define_method(:touch) do
|
||||
self.updated_at = Time.now
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Using ActiveSupport::Concern for cleaner syntax
|
||||
module Trackable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
attr_accessor :tracked_at
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def tracking_enabled?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def track!
|
||||
self.tracked_at = Time.now
|
||||
end
|
||||
end
|
||||
|
||||
class Model
|
||||
include Timestampable
|
||||
include Trackable
|
||||
end
|
||||
|
||||
# Usage
|
||||
model = Model.new
|
||||
model.touch
|
||||
model.track!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Resources & Examples
|
||||
|
||||
### Performance Patterns
|
||||
|
||||
**Memoization:**
|
||||
```ruby
|
||||
# Basic memoization
|
||||
def expensive_calculation
|
||||
@expensive_calculation ||= begin
|
||||
# Expensive operation
|
||||
sleep 1
|
||||
'result'
|
||||
end
|
||||
end
|
||||
|
||||
# Memoization with parameters
|
||||
def user_posts(user_id)
|
||||
@user_posts ||= {}
|
||||
@user_posts[user_id] ||= Post.where(user_id: user_id).to_a
|
||||
end
|
||||
|
||||
# Thread-safe memoization
|
||||
require 'concurrent'
|
||||
|
||||
class Service
|
||||
def initialize
|
||||
@cache = Concurrent::Map.new
|
||||
end
|
||||
|
||||
def get(key)
|
||||
@cache.compute_if_absent(key) do
|
||||
expensive_operation(key)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Lazy Evaluation:**
|
||||
```ruby
|
||||
# Lazy enumeration for large datasets
|
||||
(1..Float::INFINITY)
|
||||
.lazy
|
||||
.select { |n| n % 3 == 0 }
|
||||
.first(10)
|
||||
|
||||
# Lazy file processing
|
||||
File.foreach('large_file.txt').lazy
|
||||
.select { |line| line.include?('ERROR') }
|
||||
.map(&:strip)
|
||||
.first(100)
|
||||
|
||||
# Custom lazy enumerator
|
||||
def lazy_range(start, finish)
|
||||
Enumerator.new do |yielder|
|
||||
current = start
|
||||
while current <= finish
|
||||
yielder << current
|
||||
current += 1
|
||||
end
|
||||
end.lazy
|
||||
end
|
||||
```
|
||||
|
||||
**Struct for Value Objects:**
|
||||
```ruby
|
||||
# Simple value object
|
||||
User = Struct.new(:name, :email, :age) do
|
||||
def adult?
|
||||
age >= 18
|
||||
end
|
||||
|
||||
def to_s
|
||||
"#{name} <#{email}>"
|
||||
end
|
||||
end
|
||||
|
||||
# Keyword arguments (Ruby 2.5+)
|
||||
User = Struct.new(:name, :email, :age, keyword_init: true)
|
||||
user = User.new(name: 'John', email: 'john@example.com', age: 30)
|
||||
|
||||
# Data class (Ruby 3.2+)
|
||||
User = Data.define(:name, :email, :age) do
|
||||
def adult?
|
||||
age >= 18
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Error Handling Patterns
|
||||
|
||||
**Custom Exceptions:**
|
||||
```ruby
|
||||
class ApplicationError < StandardError; end
|
||||
class ValidationError < ApplicationError; end
|
||||
class NotFoundError < ApplicationError; end
|
||||
class AuthenticationError < ApplicationError; end
|
||||
|
||||
class UserService
|
||||
def create(params)
|
||||
raise ValidationError, 'Name is required' if params[:name].nil?
|
||||
|
||||
User.create(params)
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
raise NotFoundError, e.message
|
||||
end
|
||||
end
|
||||
|
||||
# Usage with rescue
|
||||
begin
|
||||
user_service.create(params)
|
||||
rescue ValidationError => e
|
||||
render json: { error: e.message }, status: 422
|
||||
rescue NotFoundError => e
|
||||
render json: { error: e.message }, status: 404
|
||||
rescue ApplicationError => e
|
||||
render json: { error: e.message }, status: 500
|
||||
end
|
||||
```
|
||||
|
||||
**Result Object Pattern:**
|
||||
```ruby
|
||||
class Result
|
||||
attr_reader :value, :error
|
||||
|
||||
def initialize(success, value, error = nil)
|
||||
@success = success
|
||||
@value = value
|
||||
@error = error
|
||||
end
|
||||
|
||||
def success?
|
||||
@success
|
||||
end
|
||||
|
||||
def failure?
|
||||
!@success
|
||||
end
|
||||
|
||||
def self.success(value)
|
||||
new(true, value)
|
||||
end
|
||||
|
||||
def self.failure(error)
|
||||
new(false, nil, error)
|
||||
end
|
||||
|
||||
def on_success(&block)
|
||||
block.call(value) if success?
|
||||
self
|
||||
end
|
||||
|
||||
def on_failure(&block)
|
||||
block.call(error) if failure?
|
||||
self
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
def create_user(params)
|
||||
user = User.new(params)
|
||||
if user.valid?
|
||||
user.save
|
||||
Result.success(user)
|
||||
else
|
||||
Result.failure(user.errors)
|
||||
end
|
||||
end
|
||||
|
||||
result = create_user(params)
|
||||
result
|
||||
.on_success { |user| send_welcome_email(user) }
|
||||
.on_failure { |errors| log_errors(errors) }
|
||||
```
|
||||
|
||||
### Testing Patterns
|
||||
|
||||
**Shared Examples:**
|
||||
```ruby
|
||||
RSpec.shared_examples 'a timestamped model' do
|
||||
it 'has created_at' do
|
||||
expect(subject).to respond_to(:created_at)
|
||||
end
|
||||
|
||||
it 'has updated_at' do
|
||||
expect(subject).to respond_to(:updated_at)
|
||||
end
|
||||
|
||||
it 'sets timestamps on create' do
|
||||
subject.save
|
||||
expect(subject.created_at).to be_present
|
||||
expect(subject.updated_at).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe User do
|
||||
it_behaves_like 'a timestamped model'
|
||||
end
|
||||
```
|
||||
|
||||
### Functional Programming Patterns
|
||||
|
||||
**Composition:**
|
||||
```ruby
|
||||
# Function composition
|
||||
add_one = ->(x) { x + 1 }
|
||||
double = ->(x) { x * 2 }
|
||||
square = ->(x) { x ** 2 }
|
||||
|
||||
# Manual composition
|
||||
result = square.call(double.call(add_one.call(5))) # ((5+1)*2)^2 = 144
|
||||
|
||||
# Compose helper
|
||||
def compose(*fns)
|
||||
->(x) { fns.reverse.reduce(x) { |acc, fn| fn.call(acc) } }
|
||||
end
|
||||
|
||||
composed = compose(square, double, add_one)
|
||||
composed.call(5) # 144
|
||||
```
|
||||
|
||||
**Immutability:**
|
||||
```ruby
|
||||
# Frozen objects
|
||||
class ImmutablePoint
|
||||
attr_reader :x, :y
|
||||
|
||||
def initialize(x, y)
|
||||
@x = x
|
||||
@y = y
|
||||
freeze
|
||||
end
|
||||
|
||||
def move(dx, dy)
|
||||
ImmutablePoint.new(@x + dx, @y + dy)
|
||||
end
|
||||
end
|
||||
|
||||
# Frozen literals (Ruby 3+)
|
||||
# frozen_string_literal: true
|
||||
|
||||
NAME = 'John' # Frozen by default
|
||||
```
|
||||
|
||||
### Additional Resources
|
||||
|
||||
See `assets/` directory for:
|
||||
- `idioms-cheatsheet.md` - Quick reference for Ruby idioms
|
||||
- `design-patterns.rb` - Complete implementations of all patterns
|
||||
- `metaprogramming-examples.rb` - Advanced metaprogramming techniques
|
||||
|
||||
See `references/` directory for:
|
||||
- Style guides and best practices
|
||||
- Performance optimization examples
|
||||
- Testing pattern library
|
||||
656
skills/sinatra-patterns/SKILL.md
Normal file
656
skills/sinatra-patterns/SKILL.md
Normal file
@@ -0,0 +1,656 @@
|
||||
---
|
||||
name: sinatra-patterns
|
||||
description: Common Sinatra patterns, routing strategies, error handling, and application organization. Use when building Sinatra applications or designing routes.
|
||||
---
|
||||
|
||||
# Sinatra Patterns Skill
|
||||
|
||||
## Tier 1: Quick Reference
|
||||
|
||||
### Common Routing Patterns
|
||||
|
||||
**Basic Routes:**
|
||||
```ruby
|
||||
get '/' do
|
||||
'Hello World'
|
||||
end
|
||||
|
||||
post '/users' do
|
||||
# Create user
|
||||
end
|
||||
|
||||
put '/users/:id' do
|
||||
# Update user
|
||||
end
|
||||
|
||||
delete '/users/:id' do
|
||||
# Delete user
|
||||
end
|
||||
```
|
||||
|
||||
**Route Parameters:**
|
||||
```ruby
|
||||
# Named parameters
|
||||
get '/users/:id' do
|
||||
User.find(params[:id])
|
||||
end
|
||||
|
||||
# Parameter constraints
|
||||
get '/users/:id', :id => /\d+/ do
|
||||
# Only matches numeric IDs
|
||||
end
|
||||
|
||||
# Wildcard
|
||||
get '/files/*.*' do
|
||||
# params['splat'] contains matched segments
|
||||
end
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
```ruby
|
||||
get '/search' do
|
||||
query = params[:q]
|
||||
page = params[:page] || 1
|
||||
results = search(query, page: page)
|
||||
end
|
||||
```
|
||||
|
||||
### Basic Middleware
|
||||
|
||||
```ruby
|
||||
# Session middleware
|
||||
use Rack::Session::Cookie, secret: ENV['SESSION_SECRET']
|
||||
|
||||
# Security middleware
|
||||
use Rack::Protection
|
||||
|
||||
# Logging
|
||||
use Rack::CommonLogger
|
||||
|
||||
# Compression
|
||||
use Rack::Deflater
|
||||
```
|
||||
|
||||
### Simple Error Handling
|
||||
|
||||
```ruby
|
||||
not_found do
|
||||
'Page not found'
|
||||
end
|
||||
|
||||
error do
|
||||
'Internal server error'
|
||||
end
|
||||
|
||||
error 401 do
|
||||
'Unauthorized'
|
||||
end
|
||||
```
|
||||
|
||||
### Helpers
|
||||
|
||||
```ruby
|
||||
helpers do
|
||||
def logged_in?
|
||||
!session[:user_id].nil?
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.find_by(id: session[:user_id])
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Detailed Instructions
|
||||
|
||||
### Advanced Routing
|
||||
|
||||
**Modular Applications:**
|
||||
```ruby
|
||||
# app/controllers/base_controller.rb
|
||||
class BaseController < Sinatra::Base
|
||||
configure do
|
||||
set :views, Proc.new { File.join(root, '../views') }
|
||||
set :public_folder, Proc.new { File.join(root, '../public') }
|
||||
end
|
||||
|
||||
helpers do
|
||||
def json_response(data, status = 200)
|
||||
content_type :json
|
||||
halt status, data.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# app/controllers/users_controller.rb
|
||||
class UsersController < BaseController
|
||||
get '/' do
|
||||
users = User.all
|
||||
json_response(users.map(&:to_hash))
|
||||
end
|
||||
|
||||
get '/:id' do
|
||||
user = User.find(params[:id]) || halt(404)
|
||||
json_response(user.to_hash)
|
||||
end
|
||||
|
||||
post '/' do
|
||||
user = User.create(params[:user])
|
||||
if user.persisted?
|
||||
json_response(user.to_hash, 201)
|
||||
else
|
||||
json_response({ errors: user.errors }, 422)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# config.ru
|
||||
map '/users' do
|
||||
run UsersController
|
||||
end
|
||||
```
|
||||
|
||||
**Namespaces:**
|
||||
```ruby
|
||||
require 'sinatra/namespace'
|
||||
|
||||
class App < Sinatra::Base
|
||||
register Sinatra::Namespace
|
||||
|
||||
namespace '/api' do
|
||||
namespace '/v1' do
|
||||
get '/users' do
|
||||
# GET /api/v1/users
|
||||
end
|
||||
|
||||
namespace '/admin' do
|
||||
before do
|
||||
authenticate_admin!
|
||||
end
|
||||
|
||||
get '/stats' do
|
||||
# GET /api/v1/admin/stats
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Route Conditions:**
|
||||
```ruby
|
||||
# User agent condition
|
||||
get '/', :agent => /iPhone/ do
|
||||
# Mobile version
|
||||
end
|
||||
|
||||
# Custom conditions
|
||||
set(:auth) do |role|
|
||||
condition do
|
||||
unless current_user && current_user.has_role?(role)
|
||||
halt 403
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
get '/admin', :auth => :admin do
|
||||
# Only accessible to admins
|
||||
end
|
||||
|
||||
# Host-based routing
|
||||
get '/', :host => 'admin.example.com' do
|
||||
# Admin subdomain
|
||||
end
|
||||
```
|
||||
|
||||
**Content Negotiation:**
|
||||
```ruby
|
||||
get '/users/:id', :provides => [:json, :xml, :html] do
|
||||
user = User.find(params[:id])
|
||||
|
||||
case request.accept.first.to_s
|
||||
when 'application/json'
|
||||
json user.to_json
|
||||
when 'application/xml'
|
||||
xml user.to_xml
|
||||
else
|
||||
erb :user, locals: { user: user }
|
||||
end
|
||||
end
|
||||
|
||||
# Or using provides helper
|
||||
get '/users/:id' do
|
||||
user = User.find(params[:id])
|
||||
|
||||
respond_to do |format|
|
||||
format.json { json user.to_json }
|
||||
format.xml { xml user.to_xml }
|
||||
format.html { erb :user, locals: { user: user } }
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Middleware Composition
|
||||
|
||||
**Custom Middleware:**
|
||||
```ruby
|
||||
class RequestLogger
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
start_time = Time.now
|
||||
status, headers, body = @app.call(env)
|
||||
duration = Time.now - start_time
|
||||
|
||||
puts "#{env['REQUEST_METHOD']} #{env['PATH_INFO']} - #{status} (#{duration}s)"
|
||||
|
||||
[status, headers, body]
|
||||
end
|
||||
end
|
||||
|
||||
use RequestLogger
|
||||
```
|
||||
|
||||
**Middleware Ordering:**
|
||||
```ruby
|
||||
# config.ru
|
||||
use Rack::Deflater # Compression first
|
||||
use Rack::Static # Static files
|
||||
use Rack::CommonLogger # Logging
|
||||
use Rack::Session::Cookie # Sessions
|
||||
use Rack::Protection # Security
|
||||
use CustomAuthentication # Auth
|
||||
run Application
|
||||
```
|
||||
|
||||
### Template Integration
|
||||
|
||||
**ERB Templates:**
|
||||
```ruby
|
||||
# views/layout.erb
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title><%= @title || 'My App' %></title>
|
||||
</head>
|
||||
<body>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
# views/users/index.erb
|
||||
<h1>Users</h1>
|
||||
<ul>
|
||||
<% @users.each do |user| %>
|
||||
<li><%= user.name %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
# Controller
|
||||
get '/users' do
|
||||
@users = User.all
|
||||
@title = 'User List'
|
||||
erb :'users/index'
|
||||
end
|
||||
```
|
||||
|
||||
**Inline Templates:**
|
||||
```ruby
|
||||
get '/' do
|
||||
erb :index
|
||||
end
|
||||
|
||||
__END__
|
||||
|
||||
@@layout
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body><%= yield %></body>
|
||||
</html>
|
||||
|
||||
@@index
|
||||
<h1>Welcome</h1>
|
||||
```
|
||||
|
||||
**Template Engines:**
|
||||
```ruby
|
||||
# Haml
|
||||
get '/' do
|
||||
haml :index
|
||||
end
|
||||
|
||||
# Slim
|
||||
get '/' do
|
||||
slim :index
|
||||
end
|
||||
|
||||
# Liquid (safe for user content)
|
||||
get '/' do
|
||||
liquid :index, locals: { user: current_user }
|
||||
end
|
||||
```
|
||||
|
||||
### Error Handling Patterns
|
||||
|
||||
**Comprehensive Error Handling:**
|
||||
```ruby
|
||||
class Application < Sinatra::Base
|
||||
# Development configuration
|
||||
configure :development do
|
||||
set :show_exceptions, :after_handler
|
||||
set :dump_errors, true
|
||||
end
|
||||
|
||||
# Production configuration
|
||||
configure :production do
|
||||
set :show_exceptions, false
|
||||
set :dump_errors, false
|
||||
end
|
||||
|
||||
# Specific exception handlers
|
||||
error ActiveRecord::RecordNotFound do
|
||||
status 404
|
||||
json({ error: 'Resource not found' })
|
||||
end
|
||||
|
||||
error ActiveRecord::RecordInvalid do
|
||||
status 422
|
||||
json({ error: 'Validation failed', details: env['sinatra.error'].message })
|
||||
end
|
||||
|
||||
error Sequel::NoMatchingRow do
|
||||
status 404
|
||||
json({ error: 'Not found' })
|
||||
end
|
||||
|
||||
# HTTP status handlers
|
||||
not_found do
|
||||
json({ error: 'Endpoint not found' })
|
||||
end
|
||||
|
||||
error 401 do
|
||||
json({ error: 'Unauthorized' })
|
||||
end
|
||||
|
||||
error 403 do
|
||||
json({ error: 'Forbidden' })
|
||||
end
|
||||
|
||||
error 422 do
|
||||
json({ error: 'Unprocessable entity' })
|
||||
end
|
||||
|
||||
# Catch-all error handler
|
||||
error do
|
||||
error = env['sinatra.error']
|
||||
logger.error("Error: #{error.message}")
|
||||
logger.error(error.backtrace.join("\n"))
|
||||
|
||||
status 500
|
||||
json({ error: 'Internal server error' })
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Before/After Filters
|
||||
|
||||
**Request Filters:**
|
||||
```ruby
|
||||
# Global before filter
|
||||
before do
|
||||
content_type :json
|
||||
end
|
||||
|
||||
# Path-specific filters
|
||||
before '/admin/*' do
|
||||
authenticate_admin!
|
||||
end
|
||||
|
||||
# Conditional filters
|
||||
before do
|
||||
pass unless request.path.start_with?('/api')
|
||||
authenticate_api_user!
|
||||
end
|
||||
|
||||
# After filters
|
||||
after do
|
||||
# Add CORS headers
|
||||
headers 'Access-Control-Allow-Origin' => '*'
|
||||
end
|
||||
|
||||
# Modify response
|
||||
after do
|
||||
response.body = response.body.map(&:upcase) if params[:uppercase]
|
||||
end
|
||||
```
|
||||
|
||||
### Session Management
|
||||
|
||||
**Cookie Sessions:**
|
||||
```ruby
|
||||
use Rack::Session::Cookie,
|
||||
key: 'app.session',
|
||||
secret: ENV['SESSION_SECRET'],
|
||||
expire_after: 86400, # 1 day
|
||||
secure: production?,
|
||||
httponly: true,
|
||||
same_site: :strict
|
||||
|
||||
helpers do
|
||||
def login(user)
|
||||
session[:user_id] = user.id
|
||||
session[:logged_in_at] = Time.now.to_i
|
||||
end
|
||||
|
||||
def logout
|
||||
session.clear
|
||||
end
|
||||
|
||||
def current_user
|
||||
return nil unless session[:user_id]
|
||||
@current_user ||= User.find_by(id: session[:user_id])
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Resources & Examples
|
||||
|
||||
### Full Application Example
|
||||
|
||||
See `assets/modular-app-template/` for complete modular application structure.
|
||||
|
||||
### Performance Patterns
|
||||
|
||||
**Caching:**
|
||||
```ruby
|
||||
# HTTP caching
|
||||
get '/public/data' do
|
||||
cache_control :public, max_age: 3600
|
||||
etag calculate_etag
|
||||
last_modified last_update_time
|
||||
|
||||
json PublicData.all.map(&:to_hash)
|
||||
end
|
||||
|
||||
# Fragment caching with Redis
|
||||
require 'redis'
|
||||
|
||||
helpers do
|
||||
def cache_fetch(key, expires_in: 300, &block)
|
||||
cached = REDIS.get(key)
|
||||
return JSON.parse(cached) if cached
|
||||
|
||||
data = block.call
|
||||
REDIS.setex(key, expires_in, data.to_json)
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
get '/expensive-data' do
|
||||
data = cache_fetch('expensive-data', expires_in: 600) do
|
||||
perform_expensive_query
|
||||
end
|
||||
|
||||
json data
|
||||
end
|
||||
```
|
||||
|
||||
**Streaming Responses:**
|
||||
```ruby
|
||||
# Stream large responses
|
||||
get '/large-export' do
|
||||
stream do |out|
|
||||
User.find_each do |user|
|
||||
out << user.to_csv_row
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Server-Sent Events
|
||||
get '/events', provides: 'text/event-stream' do
|
||||
stream :keep_open do |out|
|
||||
EventSource.subscribe do |event|
|
||||
out << "data: #{event.to_json}\n\n"
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Production Configuration
|
||||
|
||||
**Complete config.ru:**
|
||||
```ruby
|
||||
# config.ru
|
||||
require_relative 'config/environment'
|
||||
|
||||
# Production middleware
|
||||
if ENV['RACK_ENV'] == 'production'
|
||||
use Rack::SSL
|
||||
use Rack::Deflater
|
||||
end
|
||||
|
||||
# Static files
|
||||
use Rack::Static,
|
||||
urls: ['/css', '/js', '/images'],
|
||||
root: 'public',
|
||||
header_rules: [
|
||||
[:all, {'Cache-Control' => 'public, max-age=31536000'}]
|
||||
]
|
||||
|
||||
# Logging
|
||||
use Rack::CommonLogger
|
||||
|
||||
# Sessions
|
||||
use Rack::Session::Cookie,
|
||||
secret: ENV['SESSION_SECRET'],
|
||||
same_site: :strict,
|
||||
httponly: true,
|
||||
secure: ENV['RACK_ENV'] == 'production'
|
||||
|
||||
# Security
|
||||
use Rack::Protection,
|
||||
except: [:session_hijacking],
|
||||
use: :all
|
||||
|
||||
# Rate limiting (production)
|
||||
if ENV['RACK_ENV'] == 'production'
|
||||
require 'rack/attack'
|
||||
use Rack::Attack
|
||||
end
|
||||
|
||||
# Mount applications
|
||||
map '/api/v1' do
|
||||
run ApiV1::Application
|
||||
end
|
||||
|
||||
map '/' do
|
||||
run WebApplication
|
||||
end
|
||||
```
|
||||
|
||||
**Rack::Attack Configuration:**
|
||||
```ruby
|
||||
# config/rack_attack.rb
|
||||
class Rack::Attack
|
||||
# Throttle login attempts
|
||||
throttle('login/ip', limit: 5, period: 60) do |req|
|
||||
req.ip if req.path == '/login' && req.post?
|
||||
end
|
||||
|
||||
# Throttle API requests
|
||||
throttle('api/ip', limit: 100, period: 60) do |req|
|
||||
req.ip if req.path.start_with?('/api')
|
||||
end
|
||||
|
||||
# Block suspicious requests
|
||||
blocklist('block bad user agents') do |req|
|
||||
req.user_agent =~ /bad_bot/i
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Testing Patterns
|
||||
|
||||
See `references/testing-examples.rb` for comprehensive test patterns.
|
||||
|
||||
### Project Structure
|
||||
|
||||
**Recommended modular structure:**
|
||||
```
|
||||
app/
|
||||
controllers/
|
||||
base_controller.rb
|
||||
api_controller.rb
|
||||
users_controller.rb
|
||||
posts_controller.rb
|
||||
models/
|
||||
user.rb
|
||||
post.rb
|
||||
services/
|
||||
user_service.rb
|
||||
authentication_service.rb
|
||||
helpers/
|
||||
application_helpers.rb
|
||||
view_helpers.rb
|
||||
config/
|
||||
environment.rb
|
||||
database.yml
|
||||
puma.rb
|
||||
db/
|
||||
migrations/
|
||||
lib/
|
||||
middleware/
|
||||
custom_auth.rb
|
||||
tasks/
|
||||
public/
|
||||
css/
|
||||
js/
|
||||
images/
|
||||
views/
|
||||
layout.erb
|
||||
users/
|
||||
index.erb
|
||||
show.erb
|
||||
spec/
|
||||
controllers/
|
||||
models/
|
||||
spec_helper.rb
|
||||
config.ru
|
||||
Gemfile
|
||||
Rakefile
|
||||
README.md
|
||||
```
|
||||
|
||||
### Additional Resources
|
||||
|
||||
- **Routing Examples:** `assets/routing-examples.rb`
|
||||
- **Middleware Patterns:** `assets/middleware-patterns.rb`
|
||||
- **Modular App Template:** `assets/modular-app-template/`
|
||||
- **Production Config:** `references/production-config.rb`
|
||||
- **Testing Guide:** `references/testing-examples.rb`
|
||||
771
skills/sinatra-security/SKILL.md
Normal file
771
skills/sinatra-security/SKILL.md
Normal file
@@ -0,0 +1,771 @@
|
||||
---
|
||||
name: sinatra-security
|
||||
description: Security best practices for Sinatra applications including input validation, CSRF protection, and authentication patterns. Use when hardening applications or conducting security reviews.
|
||||
---
|
||||
|
||||
# Sinatra Security Skill
|
||||
|
||||
## Tier 1: Quick Reference - Essential Security
|
||||
|
||||
### CSRF Protection
|
||||
|
||||
```ruby
|
||||
# Enable Rack::Protection
|
||||
use Rack::Protection
|
||||
|
||||
# Or specifically CSRF
|
||||
use Rack::Protection::AuthenticityToken
|
||||
```
|
||||
|
||||
### XSS Prevention
|
||||
|
||||
```ruby
|
||||
# In ERB templates - always escape by default
|
||||
<%= user.bio %> # Escaped (safe)
|
||||
<%== user.bio %> # Raw (dangerous!)
|
||||
|
||||
# In JSON responses - use proper JSON encoding
|
||||
require 'json'
|
||||
json({ name: user.name }.to_json)
|
||||
```
|
||||
|
||||
### SQL Injection Prevention
|
||||
|
||||
```ruby
|
||||
# BAD: String interpolation
|
||||
DB["SELECT * FROM users WHERE email = '#{email}'"]
|
||||
|
||||
# GOOD: Parameterized queries
|
||||
DB["SELECT * FROM users WHERE email = ?", email]
|
||||
|
||||
# GOOD: Hash conditions
|
||||
User.where(email: email)
|
||||
```
|
||||
|
||||
### Secure Sessions
|
||||
|
||||
```ruby
|
||||
use Rack::Session::Cookie,
|
||||
secret: ENV['SESSION_SECRET'], # Long random string
|
||||
same_site: :strict,
|
||||
httponly: true,
|
||||
secure: production?
|
||||
```
|
||||
|
||||
### Input Validation
|
||||
|
||||
```ruby
|
||||
helpers do
|
||||
def validate_email(email)
|
||||
email.to_s.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
|
||||
end
|
||||
|
||||
def validate_integer(value)
|
||||
Integer(value)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
post '/users' do
|
||||
halt 400, 'Invalid email' unless validate_email(params[:email])
|
||||
# Process...
|
||||
end
|
||||
```
|
||||
|
||||
### Authentication Check
|
||||
|
||||
```ruby
|
||||
helpers do
|
||||
def authenticate!
|
||||
halt 401, json({ error: 'Unauthorized' }) unless current_user
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.find_by(id: session[:user_id])
|
||||
end
|
||||
end
|
||||
|
||||
before '/admin/*' do
|
||||
authenticate!
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Detailed Instructions - Security Implementation
|
||||
|
||||
### Comprehensive CSRF Protection
|
||||
|
||||
**Configuration:**
|
||||
```ruby
|
||||
class Application < Sinatra::Base
|
||||
# Enable CSRF protection
|
||||
use Rack::Protection::AuthenticityToken,
|
||||
except: [:json], # Skip for JSON APIs with token auth
|
||||
allow_if: -> (env) {
|
||||
# Skip for API endpoints with bearer token
|
||||
env['HTTP_AUTHORIZATION']&.start_with?('Bearer ')
|
||||
}
|
||||
|
||||
# Manual CSRF token generation
|
||||
helpers do
|
||||
def csrf_token
|
||||
session[:csrf] ||= SecureRandom.hex(32)
|
||||
end
|
||||
|
||||
def csrf_tag
|
||||
"<input type='hidden' name='authenticity_token' value='#{csrf_token}'>"
|
||||
end
|
||||
|
||||
def verify_csrf_token
|
||||
token = params[:authenticity_token] || request.env['HTTP_X_CSRF_TOKEN']
|
||||
halt 403, 'Invalid CSRF token' unless token == session[:csrf]
|
||||
end
|
||||
end
|
||||
|
||||
# Include in forms
|
||||
post '/users' do
|
||||
verify_csrf_token unless request.content_type == 'application/json'
|
||||
# Process...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**In Views:**
|
||||
```erb
|
||||
<form method="POST" action="/users">
|
||||
<%= csrf_tag %>
|
||||
<!-- form fields -->
|
||||
</form>
|
||||
```
|
||||
|
||||
**For AJAX:**
|
||||
```javascript
|
||||
// Include CSRF token in AJAX requests
|
||||
fetch('/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': document.querySelector('[name=csrf_token]').value,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
```
|
||||
|
||||
### XSS Prevention Strategies
|
||||
|
||||
**Template Escaping:**
|
||||
```ruby
|
||||
# ERB - escape by default
|
||||
<div><%= user_input %></div>
|
||||
|
||||
# Explicitly raw (only for trusted content)
|
||||
<div><%== trusted_html %></div>
|
||||
|
||||
# Sanitize user HTML
|
||||
require 'sanitize'
|
||||
|
||||
helpers do
|
||||
def sanitize_html(html)
|
||||
Sanitize.fragment(html, Sanitize::Config::RELAXED)
|
||||
end
|
||||
end
|
||||
|
||||
# In template
|
||||
<div><%= sanitize_html(user_bio) %></div>
|
||||
```
|
||||
|
||||
**JSON Responses:**
|
||||
```ruby
|
||||
# Always use proper JSON encoding
|
||||
get '/api/users/:id' do
|
||||
user = User.find(params[:id])
|
||||
|
||||
# BAD: Manual JSON construction
|
||||
# "{ \"name\": \"#{user.name}\" }" # XSS if name contains quotes
|
||||
|
||||
# GOOD: Use JSON library
|
||||
content_type :json
|
||||
{ name: user.name, bio: user.bio }.to_json
|
||||
end
|
||||
```
|
||||
|
||||
**Content Security Policy:**
|
||||
```ruby
|
||||
class Application < Sinatra::Base
|
||||
before do
|
||||
headers 'Content-Security-Policy' => [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' https://cdn.example.com",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'"
|
||||
].join('; ')
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### SQL Injection Prevention
|
||||
|
||||
**Parameterized Queries:**
|
||||
```ruby
|
||||
# Sequel
|
||||
# BAD
|
||||
DB["SELECT * FROM users WHERE name = '#{name}'"]
|
||||
|
||||
# GOOD
|
||||
DB["SELECT * FROM users WHERE name = ?", name]
|
||||
DB["SELECT * FROM users WHERE name = :name", name: name]
|
||||
|
||||
# ActiveRecord
|
||||
# BAD
|
||||
User.where("email = '#{email}'")
|
||||
|
||||
# GOOD
|
||||
User.where(email: email)
|
||||
User.where("email = ?", email)
|
||||
User.where("email = :email", email: email)
|
||||
```
|
||||
|
||||
**Input Validation:**
|
||||
```ruby
|
||||
helpers do
|
||||
def validate_sql_param(param, type: :string)
|
||||
case type
|
||||
when :integer
|
||||
Integer(param)
|
||||
when :boolean
|
||||
[true, 'true', '1', 1].include?(param)
|
||||
when :string
|
||||
param.to_s.gsub(/['";\\]/, '') # Remove dangerous chars
|
||||
else
|
||||
param
|
||||
end
|
||||
rescue ArgumentError
|
||||
halt 400, 'Invalid parameter'
|
||||
end
|
||||
end
|
||||
|
||||
get '/users/:id' do
|
||||
id = validate_sql_param(params[:id], type: :integer)
|
||||
user = User.find(id)
|
||||
json user.to_hash
|
||||
end
|
||||
```
|
||||
|
||||
### Authentication Patterns
|
||||
|
||||
**Password Authentication:**
|
||||
```ruby
|
||||
require 'bcrypt'
|
||||
|
||||
class User
|
||||
include BCrypt
|
||||
|
||||
def password=(new_password)
|
||||
@password_hash = Password.create(new_password)
|
||||
end
|
||||
|
||||
def password_hash
|
||||
@password_hash
|
||||
end
|
||||
|
||||
def authenticate(password)
|
||||
Password.new(password_hash) == password
|
||||
end
|
||||
end
|
||||
|
||||
# Registration
|
||||
post '/register' do
|
||||
user = User.new(
|
||||
email: params[:email],
|
||||
name: params[:name]
|
||||
)
|
||||
user.password = params[:password]
|
||||
user.save
|
||||
|
||||
session[:user_id] = user.id
|
||||
redirect '/dashboard'
|
||||
end
|
||||
|
||||
# Login
|
||||
post '/login' do
|
||||
user = User.find_by(email: params[:email])
|
||||
|
||||
if user&.authenticate(params[:password])
|
||||
session[:user_id] = user.id
|
||||
session[:logged_in_at] = Time.now.to_i
|
||||
|
||||
redirect '/dashboard'
|
||||
else
|
||||
halt 401, 'Invalid credentials'
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Token-Based Authentication:**
|
||||
```ruby
|
||||
require 'jwt'
|
||||
|
||||
class TokenAuth
|
||||
SECRET = ENV['JWT_SECRET']
|
||||
|
||||
def self.encode(payload, exp = 24.hours.from_now)
|
||||
payload[:exp] = exp.to_i
|
||||
JWT.encode(payload, SECRET, 'HS256')
|
||||
end
|
||||
|
||||
def self.decode(token)
|
||||
body = JWT.decode(token, SECRET, true, algorithm: 'HS256')[0]
|
||||
HashWithIndifferentAccess.new(body)
|
||||
rescue JWT::DecodeError, JWT::ExpiredSignature
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Middleware
|
||||
class JWTAuth
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
auth_header = env['HTTP_AUTHORIZATION']
|
||||
token = auth_header&.split(' ')&.last
|
||||
|
||||
if payload = TokenAuth.decode(token)
|
||||
env['current_user_id'] = payload[:user_id]
|
||||
@app.call(env)
|
||||
else
|
||||
[401, { 'Content-Type' => 'application/json' },
|
||||
['{"error": "Unauthorized"}']]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Login endpoint
|
||||
post '/api/login' do
|
||||
user = User.find_by(email: params[:email])
|
||||
|
||||
if user&.authenticate(params[:password])
|
||||
token = TokenAuth.encode(user_id: user.id)
|
||||
json({ token: token, user: user.to_hash })
|
||||
else
|
||||
halt 401, json({ error: 'Invalid credentials' })
|
||||
end
|
||||
end
|
||||
|
||||
# Protected routes
|
||||
class API < Sinatra::Base
|
||||
use JWTAuth
|
||||
|
||||
helpers do
|
||||
def current_user
|
||||
@current_user ||= User.find(request.env['current_user_id'])
|
||||
end
|
||||
end
|
||||
|
||||
get '/profile' do
|
||||
json current_user.to_hash
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**API Key Authentication:**
|
||||
```ruby
|
||||
class APIKeyAuth
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
api_key = env['HTTP_X_API_KEY']
|
||||
|
||||
if valid_api_key?(api_key)
|
||||
user = User.find_by(api_key: api_key)
|
||||
env['current_user'] = user
|
||||
@app.call(env)
|
||||
else
|
||||
[401, { 'Content-Type' => 'application/json' },
|
||||
['{"error": "Invalid API key"}']]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_api_key?(key)
|
||||
key && User.exists?(api_key: key, active: true)
|
||||
end
|
||||
end
|
||||
|
||||
use APIKeyAuth
|
||||
|
||||
# Generate API keys
|
||||
helpers do
|
||||
def generate_api_key
|
||||
SecureRandom.hex(32)
|
||||
end
|
||||
end
|
||||
|
||||
post '/api/keys' do
|
||||
authenticate!
|
||||
api_key = generate_api_key
|
||||
current_user.update(api_key: api_key)
|
||||
json({ api_key: api_key })
|
||||
end
|
||||
```
|
||||
|
||||
### Authorization Patterns
|
||||
|
||||
**Role-Based Access Control:**
|
||||
```ruby
|
||||
class User
|
||||
ROLES = [:guest, :user, :admin, :superadmin]
|
||||
|
||||
def has_role?(role)
|
||||
ROLES.index(self.role) >= ROLES.index(role)
|
||||
end
|
||||
|
||||
def can?(action, resource)
|
||||
case role
|
||||
when :admin, :superadmin
|
||||
true
|
||||
when :user
|
||||
action == :read || resource.user_id == id
|
||||
else
|
||||
action == :read
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
helpers do
|
||||
def authorize!(action, resource)
|
||||
unless current_user&.can?(action, resource)
|
||||
halt 403, json({ error: 'Forbidden' })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
get '/posts/:id' do
|
||||
post = Post.find(params[:id])
|
||||
authorize!(:read, post)
|
||||
json post.to_hash
|
||||
end
|
||||
|
||||
delete '/posts/:id' do
|
||||
post = Post.find(params[:id])
|
||||
authorize!(:delete, post)
|
||||
post.destroy
|
||||
status 204
|
||||
end
|
||||
```
|
||||
|
||||
**Permission-Based Authorization:**
|
||||
```ruby
|
||||
class Permission
|
||||
ACTIONS = {
|
||||
posts: [:create, :read, :update, :delete],
|
||||
users: [:read, :update, :delete],
|
||||
comments: [:create, :read, :delete]
|
||||
}
|
||||
|
||||
def self.check(user, action, resource_type)
|
||||
return false unless user
|
||||
|
||||
permissions = user.permissions
|
||||
permissions.include?("#{resource_type}:#{action}") ||
|
||||
permissions.include?("#{resource_type}:*") ||
|
||||
permissions.include?("*:*")
|
||||
end
|
||||
end
|
||||
|
||||
helpers do
|
||||
def can?(action, resource_type)
|
||||
Permission.check(current_user, action, resource_type)
|
||||
end
|
||||
|
||||
def authorize!(action, resource_type)
|
||||
unless can?(action, resource_type)
|
||||
halt 403, json({ error: 'Forbidden' })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
post '/posts' do
|
||||
authorize!(:create, :posts)
|
||||
# Create post
|
||||
end
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**Using Rack::Attack:**
|
||||
```ruby
|
||||
require 'rack/attack'
|
||||
|
||||
class Rack::Attack
|
||||
# Throttle login attempts
|
||||
throttle('login/ip', limit: 5, period: 60) do |req|
|
||||
req.ip if req.path == '/login' && req.post?
|
||||
end
|
||||
|
||||
# Throttle API requests by API key
|
||||
throttle('api/key', limit: 100, period: 60) do |req|
|
||||
req.env['HTTP_X_API_KEY'] if req.path.start_with?('/api')
|
||||
end
|
||||
|
||||
# Throttle by IP
|
||||
throttle('req/ip', limit: 300, period: 60) do |req|
|
||||
req.ip
|
||||
end
|
||||
|
||||
# Block known bad actors
|
||||
blocklist('block bad IPs') do |req|
|
||||
BadIP.blocked?(req.ip)
|
||||
end
|
||||
|
||||
# Custom response
|
||||
self.throttled_responder = lambda do |env|
|
||||
[
|
||||
429,
|
||||
{ 'Content-Type' => 'application/json' },
|
||||
[{ error: 'Rate limit exceeded' }.to_json]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
use Rack::Attack
|
||||
```
|
||||
|
||||
### Secure File Uploads
|
||||
|
||||
```ruby
|
||||
require 'securerandom'
|
||||
|
||||
class FileUploadHandler
|
||||
ALLOWED_TYPES = {
|
||||
'image/jpeg' => '.jpg',
|
||||
'image/png' => '.png',
|
||||
'image/gif' => '.gif',
|
||||
'application/pdf' => '.pdf'
|
||||
}
|
||||
|
||||
MAX_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
|
||||
def self.process(file)
|
||||
# Validate file presence
|
||||
return { error: 'No file provided' } unless file
|
||||
|
||||
# Validate file size
|
||||
if file[:tempfile].size > MAX_SIZE
|
||||
return { error: 'File too large' }
|
||||
end
|
||||
|
||||
# Validate content type
|
||||
content_type = file[:type]
|
||||
unless ALLOWED_TYPES.key?(content_type)
|
||||
return { error: 'Invalid file type' }
|
||||
end
|
||||
|
||||
# Sanitize filename
|
||||
original_name = File.basename(file[:filename])
|
||||
sanitized_name = original_name.gsub(/[^a-zA-Z0-9\._-]/, '')
|
||||
|
||||
# Generate unique filename
|
||||
extension = ALLOWED_TYPES[content_type]
|
||||
unique_name = "#{SecureRandom.hex(16)}#{extension}"
|
||||
|
||||
# Save file
|
||||
upload_dir = 'uploads'
|
||||
FileUtils.mkdir_p(upload_dir)
|
||||
path = File.join(upload_dir, unique_name)
|
||||
|
||||
File.open(path, 'wb') do |f|
|
||||
f.write(file[:tempfile].read)
|
||||
end
|
||||
|
||||
{ success: true, path: path, filename: unique_name }
|
||||
end
|
||||
end
|
||||
|
||||
post '/upload' do
|
||||
result = FileUploadHandler.process(params[:file])
|
||||
|
||||
if result[:error]
|
||||
halt 400, json({ error: result[:error] })
|
||||
else
|
||||
json({ url: "/uploads/#{result[:filename]}" })
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Resources & Examples
|
||||
|
||||
### Security Headers
|
||||
|
||||
**Comprehensive Security Headers:**
|
||||
```ruby
|
||||
class SecurityHeaders
|
||||
HEADERS = {
|
||||
'X-Frame-Options' => 'DENY',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'X-XSS-Protection' => '1; mode=block',
|
||||
'Referrer-Policy' => 'strict-origin-when-cross-origin',
|
||||
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()',
|
||||
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains'
|
||||
}
|
||||
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
status, headers, body = @app.call(env)
|
||||
headers.merge!(HEADERS)
|
||||
[status, headers, body]
|
||||
end
|
||||
end
|
||||
|
||||
use SecurityHeaders
|
||||
```
|
||||
|
||||
### OWASP Security Checklist
|
||||
|
||||
See `assets/owasp-checklist.md` for complete checklist covering:
|
||||
|
||||
1. **Injection Prevention**
|
||||
- SQL Injection
|
||||
- Command Injection
|
||||
- LDAP Injection
|
||||
- XML Injection
|
||||
|
||||
2. **Broken Authentication**
|
||||
- Password policies
|
||||
- Session management
|
||||
- Multi-factor authentication
|
||||
- Account lockout
|
||||
|
||||
3. **Sensitive Data Exposure**
|
||||
- Encryption at rest
|
||||
- Encryption in transit (HTTPS)
|
||||
- Secure key storage
|
||||
- Data minimization
|
||||
|
||||
4. **XML External Entities (XXE)**
|
||||
- XML parser configuration
|
||||
- Disable external entity processing
|
||||
|
||||
5. **Broken Access Control**
|
||||
- Authentication on all protected routes
|
||||
- Authorization checks
|
||||
- IDOR prevention
|
||||
- CORS configuration
|
||||
|
||||
6. **Security Misconfiguration**
|
||||
- Remove default credentials
|
||||
- Disable directory listing
|
||||
- Error message handling
|
||||
- Keep dependencies updated
|
||||
|
||||
7. **Cross-Site Scripting (XSS)**
|
||||
- Output encoding
|
||||
- Input validation
|
||||
- Content Security Policy
|
||||
- HTTPOnly cookies
|
||||
|
||||
8. **Insecure Deserialization**
|
||||
- Validate serialized data
|
||||
- Use safe serialization formats
|
||||
- Sign serialized data
|
||||
|
||||
9. **Using Components with Known Vulnerabilities**
|
||||
- Regular dependency updates
|
||||
- Security audits (bundle audit)
|
||||
- Monitor CVE databases
|
||||
|
||||
10. **Insufficient Logging & Monitoring**
|
||||
- Log security events
|
||||
- Monitor for attacks
|
||||
- Alerting systems
|
||||
- Log rotation and retention
|
||||
|
||||
### Security Testing Examples
|
||||
|
||||
**Testing Authentication:**
|
||||
```ruby
|
||||
RSpec.describe 'Authentication' do
|
||||
describe 'POST /login' do
|
||||
let(:user) { create(:user, email: 'test@example.com', password: 'password123') }
|
||||
|
||||
it 'succeeds with valid credentials' do
|
||||
post '/login', { email: 'test@example.com', password: 'password123' }.to_json,
|
||||
'CONTENT_TYPE' => 'application/json'
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(json_response).to have_key('token')
|
||||
end
|
||||
|
||||
it 'fails with invalid password' do
|
||||
post '/login', { email: 'test@example.com', password: 'wrong' }.to_json,
|
||||
'CONTENT_TYPE' => 'application/json'
|
||||
|
||||
expect(last_response.status).to eq(401)
|
||||
end
|
||||
|
||||
it 'prevents brute force attacks' do
|
||||
6.times do
|
||||
post '/login', { email: 'test@example.com', password: 'wrong' }.to_json,
|
||||
'CONTENT_TYPE' => 'application/json'
|
||||
end
|
||||
|
||||
expect(last_response.status).to eq(429) # Rate limited
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Testing Authorization:**
|
||||
```ruby
|
||||
RSpec.describe 'Authorization' do
|
||||
let(:user) { create(:user) }
|
||||
let(:admin) { create(:user, role: :admin) }
|
||||
let(:post) { create(:post, user: user) }
|
||||
|
||||
describe 'DELETE /posts/:id' do
|
||||
it 'allows owner to delete' do
|
||||
delete "/posts/#{post.id}", {}, auth_header(user.token)
|
||||
expect(last_response.status).to eq(204)
|
||||
end
|
||||
|
||||
it 'allows admin to delete' do
|
||||
delete "/posts/#{post.id}", {}, auth_header(admin.token)
|
||||
expect(last_response.status).to eq(204)
|
||||
end
|
||||
|
||||
it 'denies other users' do
|
||||
other_user = create(:user)
|
||||
delete "/posts/#{post.id}", {}, auth_header(other_user.token)
|
||||
expect(last_response.status).to eq(403)
|
||||
end
|
||||
|
||||
it 'requires authentication' do
|
||||
delete "/posts/#{post.id}"
|
||||
expect(last_response.status).to eq(401)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Additional Resources
|
||||
|
||||
- **Security Middleware:** `assets/security-middleware.rb`
|
||||
- **Authentication Patterns:** `assets/auth-patterns.rb`
|
||||
- **OWASP Checklist:** `assets/owasp-checklist.md`
|
||||
- **Security Audit Template:** `references/security-audit-template.md`
|
||||
- **Penetration Testing Guide:** `references/penetration-testing.md`
|
||||
Reference in New Issue
Block a user