aris 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +342 -0
  4. data/lib/aris/adapters/base.rb +25 -0
  5. data/lib/aris/adapters/joys_integration.rb +94 -0
  6. data/lib/aris/adapters/mock/adapter.rb +141 -0
  7. data/lib/aris/adapters/mock/request.rb +81 -0
  8. data/lib/aris/adapters/mock/response.rb +17 -0
  9. data/lib/aris/adapters/rack/adapter.rb +117 -0
  10. data/lib/aris/adapters/rack/request.rb +66 -0
  11. data/lib/aris/adapters/rack/response.rb +16 -0
  12. data/lib/aris/core.rb +931 -0
  13. data/lib/aris/discovery.rb +312 -0
  14. data/lib/aris/locale_injector.rb +39 -0
  15. data/lib/aris/pipeline_runner.rb +100 -0
  16. data/lib/aris/plugins/api_key_auth.rb +61 -0
  17. data/lib/aris/plugins/basic_auth.rb +68 -0
  18. data/lib/aris/plugins/bearer_auth.rb +64 -0
  19. data/lib/aris/plugins/cache.rb +120 -0
  20. data/lib/aris/plugins/compression.rb +96 -0
  21. data/lib/aris/plugins/cookies.rb +46 -0
  22. data/lib/aris/plugins/cors.rb +81 -0
  23. data/lib/aris/plugins/csrf.rb +48 -0
  24. data/lib/aris/plugins/etag.rb +90 -0
  25. data/lib/aris/plugins/flash.rb +124 -0
  26. data/lib/aris/plugins/form_parser.rb +46 -0
  27. data/lib/aris/plugins/health_check.rb +62 -0
  28. data/lib/aris/plugins/json.rb +32 -0
  29. data/lib/aris/plugins/multipart.rb +160 -0
  30. data/lib/aris/plugins/rate_limiter.rb +60 -0
  31. data/lib/aris/plugins/request_id.rb +38 -0
  32. data/lib/aris/plugins/request_logger.rb +43 -0
  33. data/lib/aris/plugins/security_headers.rb +99 -0
  34. data/lib/aris/plugins/session.rb +175 -0
  35. data/lib/aris/plugins.rb +23 -0
  36. data/lib/aris/response_helpers.rb +156 -0
  37. data/lib/aris/route_helpers.rb +141 -0
  38. data/lib/aris/utils/redirects.rb +44 -0
  39. data/lib/aris/utils/sitemap.rb +84 -0
  40. data/lib/aris/version.rb +3 -0
  41. data/lib/aris.rb +35 -0
  42. metadata +151 -0
@@ -0,0 +1,120 @@
1
+ # lib/aris/plugins/cache.rb
2
+ require 'digest'
3
+
4
+ module Aris
5
+ module Plugins
6
+ class Cache
7
+ attr_reader :config
8
+
9
+ def initialize(**config)
10
+ @config = config
11
+ @ttl = config[:ttl] || 60 # Default 1 minute
12
+ @store = config[:store] || {} # In-memory hash
13
+ @mutex = Mutex.new
14
+ @skip_paths = Array(config[:skip_paths] || [])
15
+ @cache_control = config[:cache_control]
16
+ end
17
+
18
+ def call(request, response)
19
+ # Only cache GET requests
20
+ return nil unless request.method == 'GET'
21
+
22
+ # Skip if path matches skip pattern
23
+ return nil if @skip_paths.any? { |pattern| request.path.match?(pattern) }
24
+
25
+ # Check if client sent Cache-Control: no-cache
26
+ cache_control = request.headers['HTTP_CACHE_CONTROL']
27
+ return nil if cache_control&.include?('no-cache')
28
+
29
+ # Generate cache key
30
+ cache_key = generate_cache_key(request)
31
+
32
+ # Try to get from cache
33
+ cached = get_from_cache(cache_key)
34
+ if cached
35
+ # Cache hit - restore response from cache
36
+ response.status = cached[:status]
37
+ response.headers.merge!(cached[:headers])
38
+ response.body = cached[:body]
39
+ response.headers['X-Cache'] = 'HIT'
40
+
41
+ return response # Halt pipeline
42
+ end
43
+
44
+ # Cache miss - store request info for response phase
45
+ request.instance_variable_set(:@cache_key, cache_key)
46
+
47
+ nil # Continue to handler
48
+ end
49
+
50
+ # Response plugin - cache the result
51
+ def call_response(request, response)
52
+ # Only cache successful GET responses
53
+ return unless request.method == 'GET'
54
+ return unless response.status == 200
55
+
56
+ cache_key = request.instance_variable_get(:@cache_key)
57
+ return unless cache_key
58
+
59
+ # Store in cache
60
+ set_in_cache(cache_key, {
61
+ status: response.status,
62
+ headers: response.headers.dup,
63
+ body: response.body.dup
64
+ })
65
+
66
+ # Add cache headers
67
+ response.headers['X-Cache'] = 'MISS'
68
+ if @cache_control
69
+ response.headers['Cache-Control'] = @cache_control
70
+ end
71
+ end
72
+
73
+ def self.build(**config)
74
+ new(**config)
75
+ end
76
+
77
+ # For testing: clear cache
78
+ def clear!
79
+ @mutex.synchronize { @store.clear }
80
+ end
81
+
82
+ private
83
+
84
+ def generate_cache_key(request)
85
+ # Include domain, path, and query string
86
+ key_string = "#{request.domain}:#{request.path}"
87
+ key_string += "?#{request.query}" unless request.query.nil? || request.query.empty?
88
+
89
+ Digest::MD5.hexdigest(key_string)
90
+ end
91
+
92
+ def get_from_cache(key)
93
+ @mutex.synchronize do
94
+ entry = @store[key]
95
+ return nil unless entry
96
+
97
+ # Check if expired
98
+ if Time.now > entry[:expires_at]
99
+ @store.delete(key)
100
+ return nil
101
+ end
102
+
103
+ entry[:value]
104
+ end
105
+ end
106
+
107
+ def set_in_cache(key, value)
108
+ @mutex.synchronize do
109
+ @store[key] = {
110
+ value: value,
111
+ expires_at: Time.now + @ttl
112
+ }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+
120
+ Aris.register_plugin(:cache, plugin_class: Aris::Plugins::Cache)
@@ -0,0 +1,96 @@
1
+ # lib/aris/plugins/compression.rb
2
+ require 'zlib'
3
+ require 'stringio'
4
+
5
+ module Aris
6
+ module Plugins
7
+ class Compression
8
+ attr_reader :config
9
+
10
+ # Compressible content types
11
+ COMPRESSIBLE_TYPES = [
12
+ 'text/',
13
+ 'application/json',
14
+ 'application/javascript',
15
+ 'application/xml',
16
+ 'application/xhtml+xml'
17
+ ].freeze
18
+
19
+ def initialize(**config)
20
+ @config = config
21
+ @level = config[:level] || Zlib::DEFAULT_COMPRESSION
22
+ @min_size = config[:min_size] || 1024 # Don't compress < 1KB
23
+ end
24
+
25
+ # Request hook - not used, but kept for compatibility
26
+ def call(request, response)
27
+ nil # Do nothing on request phase
28
+ end
29
+
30
+ # Response hook - runs after handler
31
+ def call_response(request, response)
32
+ # Only compress if client accepts gzip
33
+ accept_encoding = request.headers['HTTP_ACCEPT_ENCODING'] || ''
34
+ return unless accept_encoding.include?('gzip')
35
+
36
+ # Only compress if we have a body
37
+ return if response.body.nil? || response.body.empty?
38
+
39
+ # Get body as string
40
+ body_string = response.body.join
41
+
42
+ # Skip if too small
43
+ return if body_string.bytesize < @min_size
44
+
45
+ # Only compress compressible content types
46
+ content_type = response.headers['content-type'] || ''
47
+ return unless compressible?(content_type)
48
+
49
+ # Compress the body
50
+ compressed = compress_gzip(body_string)
51
+
52
+ # Only use compression if it actually saves space
53
+ if compressed.bytesize < body_string.bytesize
54
+ response.body = [compressed]
55
+ response.headers['Content-Encoding'] = 'gzip'
56
+ response.headers['Vary'] = add_vary_header(response.headers['Vary'])
57
+ response.headers.delete('Content-Length') # Will be recalculated by server
58
+ end
59
+ end
60
+
61
+ def self.build(**config)
62
+ new(**config)
63
+ end
64
+
65
+ private
66
+
67
+ def compressible?(content_type)
68
+ COMPRESSIBLE_TYPES.any? { |type| content_type.start_with?(type) }
69
+ end
70
+
71
+ def compress_gzip(data)
72
+ output = StringIO.new
73
+ output.set_encoding('ASCII-8BIT')
74
+
75
+ gz = Zlib::GzipWriter.new(output, @level)
76
+ gz.write(data)
77
+ gz.close
78
+
79
+ output.string
80
+ end
81
+
82
+ def add_vary_header(existing_vary)
83
+ if existing_vary.nil? || existing_vary.empty?
84
+ 'Accept-Encoding'
85
+ elsif existing_vary.include?('Accept-Encoding')
86
+ existing_vary
87
+ else
88
+ "#{existing_vary}, Accept-Encoding"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ # Self-register
96
+ Aris.register_plugin(:compression, plugin_class: Aris::Plugins::Compression)
@@ -0,0 +1,46 @@
1
+ # lib/aris/plugins/cookies.rb
2
+ module Aris
3
+ module Plugins
4
+ class Cookies
5
+ def self.call(request, response)
6
+ # Only add cookie helpers when plugin is used
7
+ add_cookie_helpers(request, response)
8
+ nil # Continue pipeline
9
+ end
10
+
11
+ def self.build(**config)
12
+ self
13
+ end
14
+
15
+ private
16
+
17
+ def self.add_cookie_helpers(request, response)
18
+ # Add cookie writing methods to response
19
+ response.define_singleton_method(:set_cookie) do |name, value, options = {}|
20
+ default_options = Aris::Config.cookie_options || {}
21
+ merged_options = default_options.merge(options)
22
+
23
+ cookie_parts = ["#{name}=#{value}"]
24
+ cookie_parts << "Path=#{merged_options[:path]}" if merged_options[:path]
25
+ cookie_parts << "HttpOnly" if merged_options[:httponly]
26
+ cookie_parts << "Secure" if merged_options[:secure]
27
+ cookie_parts << "Max-Age=#{merged_options[:max_age]}" if merged_options[:max_age]
28
+ cookie_parts << "SameSite=#{merged_options[:same_site]}" if merged_options[:same_site]
29
+
30
+ cookie_string = cookie_parts.join("; ")
31
+
32
+ if headers['Set-Cookie']
33
+ headers['Set-Cookie'] = [headers['Set-Cookie'], cookie_string].join(", ")
34
+ else
35
+ headers['Set-Cookie'] = cookie_string
36
+ end
37
+ end
38
+
39
+ response.define_singleton_method(:delete_cookie) do |name, options = {}|
40
+ set_cookie(name, "", options.merge(max_age: 0))
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ Aris.register_plugin(:cookies, plugin_class: Aris::Plugins::Cookies)
@@ -0,0 +1,81 @@
1
+ # lib/aris/plugins/cors.rb
2
+
3
+ module Aris
4
+ module Plugins
5
+ class Cors
6
+ attr_reader :config
7
+
8
+ def initialize(**config)
9
+ @config = config
10
+ @origins = normalize_origins(config[:origins] || '*')
11
+ @methods = config[:methods] || ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
12
+ @headers = config[:headers] || ['content-type', 'Authorization']
13
+ @credentials = config[:credentials] || false
14
+ @max_age = config[:max_age] || 86400 # 24 hours
15
+ @expose_headers = config[:expose_headers] || []
16
+ end
17
+
18
+ def call(request, response)
19
+ origin = request.headers['HTTP_ORIGIN']
20
+
21
+ # No Origin header = not a CORS request
22
+ return nil unless origin
23
+
24
+ # Check if origin is allowed
25
+ unless origin_allowed?(origin)
26
+ return nil # Don't set CORS headers for disallowed origins
27
+ end
28
+
29
+ # Set CORS headers
30
+ response.headers['Access-Control-Allow-Origin'] = allowed_origin_header(origin)
31
+ response.headers['Access-Control-Allow-Methods'] = @methods.join(', ')
32
+ response.headers['Access-Control-Allow-Headers'] = @headers.join(', ')
33
+ response.headers['Access-Control-Max-Age'] = @max_age.to_s
34
+
35
+ if @credentials
36
+ response.headers['Access-Control-Allow-Credentials'] = 'true'
37
+ end
38
+
39
+ if @expose_headers.any?
40
+ response.headers['Access-Control-Expose-Headers'] = @expose_headers.join(', ')
41
+ end
42
+
43
+ # Handle preflight OPTIONS request
44
+ if request.method == 'OPTIONS'
45
+ response.status = 204
46
+ response.body = []
47
+ return response # Halt - don't proceed to handler
48
+ end
49
+
50
+ nil # Continue for actual requests
51
+ end
52
+
53
+ def self.build(**config)
54
+ new(**config)
55
+ end
56
+
57
+ private
58
+
59
+ def normalize_origins(origins)
60
+ return '*' if origins == '*'
61
+ Array(origins)
62
+ end
63
+
64
+ def origin_allowed?(origin)
65
+ return true if @origins == '*'
66
+ @origins.include?(origin)
67
+ end
68
+
69
+ def allowed_origin_header(origin)
70
+ # If credentials true, must echo specific origin (can't use *)
71
+ if @credentials && @origins == '*'
72
+ origin
73
+ elsif @origins == '*'
74
+ '*'
75
+ else
76
+ origin
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,48 @@
1
+ require 'securerandom'
2
+ module Aris
3
+ module Plugins
4
+ module CsrfUtility
5
+ extend self
6
+ def generate_token
7
+ SecureRandom.urlsafe_base64(32)
8
+ end
9
+ def validate_token(expected, provided)
10
+ expected && provided && expected == provided
11
+ end
12
+ end
13
+
14
+ CSRF_THREAD_KEY = :aris_csrf_token
15
+ FORM_METHODS = %w[POST PUT PATCH DELETE].freeze
16
+
17
+ class CsrfTokenGenerator
18
+ def self.call(request, response)
19
+ if request.method == 'GET' || request.method == 'HEAD'
20
+ token = CsrfUtility.generate_token
21
+ Thread.current[CSRF_THREAD_KEY] = token
22
+ end
23
+ nil # Continue pipeline
24
+ end
25
+ end
26
+
27
+ class CsrfProtection
28
+ def self.call(request, response)
29
+ return nil unless FORM_METHODS.include?(request.method)
30
+ expected = Thread.current[CSRF_THREAD_KEY]
31
+ provided = request.headers['HTTP_X_CSRF_TOKEN']
32
+ unless CsrfUtility.validate_token(expected, provided)
33
+ response.status = 403
34
+ response.headers['content-type'] = 'text/plain'
35
+ response.body = ['CSRF token validation failed']
36
+ return response
37
+ end
38
+
39
+ nil
40
+ end
41
+ end
42
+
43
+ end
44
+ end
45
+ Aris.register_plugin(:csrf,
46
+ generator: Aris::Plugins::CsrfTokenGenerator,
47
+ protection: Aris::Plugins::CsrfProtection
48
+ )
@@ -0,0 +1,90 @@
1
+ # lib/aris/plugins/etag.rb
2
+ require 'digest'
3
+
4
+ module Aris
5
+ module Plugins
6
+ class ETag
7
+ attr_reader :config
8
+
9
+ def initialize(**config)
10
+ @config = config
11
+ @cache_control = config[:cache_control] || 'max-age=0, private, must-revalidate'
12
+ @strong = config[:strong].nil? ? true : config[:strong]
13
+ end
14
+
15
+ # Request hook - check If-None-Match
16
+ def call(request, response)
17
+ if_none_match = request.headers['HTTP_IF_NONE_MATCH']
18
+
19
+ # Store for later comparison in response phase
20
+ request.instance_variable_set(:@if_none_match, if_none_match)
21
+
22
+ nil # Continue to handler
23
+ end
24
+
25
+ # Response hook - generate ETag and check match
26
+ def call_response(request, response)
27
+ # Only generate ETags for successful GET/HEAD requests
28
+ return unless %w[GET HEAD].include?(request.method)
29
+ return unless response.status == 200
30
+
31
+ # Skip if response already has ETag
32
+ return if response.headers['ETag']
33
+
34
+ # Generate ETag from body
35
+ body_string = response.body.join
36
+ etag = generate_etag(body_string)
37
+
38
+ # Set ETag header
39
+ response.headers['ETag'] = etag
40
+
41
+ # Set Cache-Control if not already set
42
+ unless response.headers['Cache-Control']
43
+ response.headers['Cache-Control'] = @cache_control
44
+ end
45
+
46
+ # Check if client's ETag matches
47
+ if_none_match = request.instance_variable_get(:@if_none_match)
48
+ if if_none_match && etag_match?(if_none_match, etag)
49
+ # Return 304 Not Modified
50
+ response.status = 304
51
+ response.body = []
52
+ end
53
+ end
54
+
55
+ def self.build(**config)
56
+ new(**config)
57
+ end
58
+
59
+ private
60
+
61
+ def generate_etag(body)
62
+ hash = Digest::MD5.hexdigest(body)
63
+ if @strong
64
+ %("#{hash}") # Strong ETag with quotes
65
+ else
66
+ %(W/"#{hash}") # Weak ETag
67
+ end
68
+ end
69
+
70
+ def etag_match?(if_none_match, etag)
71
+ # Handle multiple ETags in If-None-Match
72
+ client_etags = if_none_match.split(',').map(&:strip)
73
+
74
+ # Check if any client ETag matches
75
+ client_etags.any? do |client_etag|
76
+ # Strip W/ prefix for weak comparison
77
+ normalize_etag(client_etag) == normalize_etag(etag)
78
+ end
79
+ end
80
+
81
+ def normalize_etag(etag)
82
+ # Remove W/ prefix and quotes for comparison
83
+ etag.sub(/^W\//, '').gsub('"', '')
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ # Self-register
90
+ Aris.register_plugin(:etag, plugin_class: Aris::Plugins::ETag)
@@ -0,0 +1,124 @@
1
+ # lib/aris/plugins/flash.rb - Complete rewrite of FlashData
2
+ require 'json'
3
+ require 'base64'
4
+ require 'set'
5
+
6
+ module Aris
7
+ module Plugins
8
+ class Flash
9
+ def self.call(request, response)
10
+ # Load flash data from cookie and initialize flash object
11
+ flash_data = load_flash_from_cookie(request)
12
+
13
+ # Define flash method on request
14
+ request.define_singleton_method(:flash) do
15
+ @flash ||= flash_data
16
+ end
17
+
18
+ nil # Continue pipeline
19
+ end
20
+
21
+ def self.call_response(request, response)
22
+ return unless request.respond_to?(:flash)
23
+
24
+ flash = request.flash
25
+ data_to_store = flash.to_store
26
+
27
+ if data_to_store.any?
28
+ # Store flash for next request
29
+ encoded = Base64.urlsafe_encode64(data_to_store.to_json)
30
+ response.set_cookie('_aris_flash', encoded, {
31
+ httponly: true,
32
+ path: '/'
33
+ })
34
+ elsif request.cookies && request.cookies['_aris_flash']
35
+ # Clear flash cookie if no data to store
36
+ response.delete_cookie('_aris_flash')
37
+ end
38
+ end
39
+
40
+ def self.build(**config)
41
+ self
42
+ end
43
+
44
+ private
45
+
46
+ def self.load_flash_from_cookie(request)
47
+ return FlashData.new unless request.respond_to?(:cookies)
48
+
49
+ cookie_value = request.cookies['_aris_flash']
50
+ return FlashData.new unless cookie_value && !cookie_value.empty?
51
+
52
+ begin
53
+ decoded = Base64.urlsafe_decode64(cookie_value)
54
+ data = JSON.parse(decoded, symbolize_names: true)
55
+ FlashData.new(data)
56
+ rescue StandardError
57
+ # If cookie is invalid, start with empty flash
58
+ FlashData.new
59
+ end
60
+ end
61
+
62
+ # Internal flash data storage
63
+ # Alternative FlashData implementation with more explicit tracking
64
+ class FlashData
65
+ def initialize(initial_data = {})
66
+ @current = initial_data || {}
67
+ @next = {}
68
+ @now = {}
69
+ # Use a simple array to track reads
70
+ @read_keys = []
71
+ end
72
+
73
+ def [](key)
74
+ key = key.to_sym
75
+
76
+ # Check now first
77
+ return @now[key] if @now.key?(key)
78
+
79
+ # Check current
80
+ if @current.key?(key) && !@read_keys.include?(key)
81
+ value = @current[key]
82
+ @read_keys << key
83
+ return value
84
+ end
85
+
86
+ # Check next
87
+ @next[key]
88
+ end
89
+
90
+ def []=(key, value)
91
+ @next[key.to_sym] = value
92
+ end
93
+
94
+ def now
95
+ FlashNow.new(@now)
96
+ end
97
+
98
+ def any?
99
+ @current.any? || @next.any? || @now.any?
100
+ end
101
+
102
+ def to_store
103
+ # Remove any keys that have been read
104
+ unused_current = @current.reject { |k, _| @read_keys.include?(k) }
105
+ unused_current.merge(@next)
106
+ end
107
+ end
108
+ # Flash.now proxy - only modifies current request data
109
+ class FlashNow
110
+ def initialize(now_data)
111
+ @now_data = now_data
112
+ end
113
+
114
+ def [](key)
115
+ @now_data[key.to_sym]
116
+ end
117
+
118
+ def []=(key, value)
119
+ @now_data[key.to_sym] = value
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,46 @@
1
+ # lib/aris/plugins/form_parser.rb
2
+ require 'rack/utils'
3
+
4
+ module Aris
5
+ module Plugins
6
+ class FormParser
7
+ attr_reader :config
8
+
9
+ PARSEABLE_METHODS = %w[POST PUT PATCH].freeze
10
+
11
+ def initialize(**config)
12
+ @config = config
13
+ end
14
+
15
+ def call(request, response)
16
+ return nil unless PARSEABLE_METHODS.include?(request.method)
17
+
18
+ # Check content-type - access from env, not headers
19
+ content_type = request.env['CONTENT_TYPE']
20
+ return nil unless content_type&.include?('application/x-www-form-urlencoded')
21
+
22
+ raw_body = request.body
23
+ return nil if raw_body.nil? || raw_body.empty?
24
+
25
+ begin
26
+ # Parse form data
27
+ data = ::Rack::Utils.parse_nested_query(raw_body)
28
+
29
+ # Attach parsed data to request
30
+ request.instance_variable_set(:@form_data, data)
31
+ rescue => e
32
+ response.status = 400
33
+ response.headers['content-type'] = 'text/plain'
34
+ response.body = ['Invalid form data']
35
+ return response
36
+ end
37
+
38
+ nil # Continue pipeline
39
+ end
40
+
41
+ def self.build(**config)
42
+ new(**config)
43
+ end
44
+ end
45
+ end
46
+ end