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,62 @@
1
+ # lib/aris/plugins/health_check.rb
2
+ module Aris
3
+ module Plugins
4
+ class HealthCheck
5
+ attr_reader :config
6
+
7
+ def initialize(**config)
8
+ @config = config
9
+ @path = config[:path] || '/health'
10
+ @checks = config[:checks] || {}
11
+ @name = config[:name] || 'app'
12
+ @version = config[:version]
13
+ end
14
+
15
+ def call(request, response)
16
+ # Only handle health check path
17
+ return nil unless request.path == @path
18
+
19
+ # Run health checks
20
+ results = {}
21
+ overall_healthy = true
22
+
23
+ @checks.each do |name, check_proc|
24
+ begin
25
+ check_result = check_proc.call
26
+ results[name] = check_result ? 'ok' : 'fail'
27
+ overall_healthy = false unless check_result
28
+ rescue => e
29
+ results[name] = "error: #{e.message}"
30
+ overall_healthy = false
31
+ end
32
+ end
33
+
34
+ # Build response
35
+ health_data = {
36
+ status: overall_healthy ? 'ok' : 'degraded',
37
+ name: @name,
38
+ checks: results
39
+ }
40
+
41
+ health_data[:version] = @version if @version
42
+ health_data[:timestamp] = Time.now.iso8601
43
+
44
+ # Set status code
45
+ status = overall_healthy ? 200 : 503
46
+
47
+ response.status = status
48
+ response.headers['content-type'] = 'application/json'
49
+ response.body = [health_data.to_json]
50
+
51
+ response # Halt pipeline
52
+ end
53
+
54
+ def self.build(**config)
55
+ new(**config)
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # Self-register
62
+ Aris.register_plugin(:health_check, plugin_class: Aris::Plugins::HealthCheck)
@@ -0,0 +1,32 @@
1
+ require 'json'
2
+
3
+ module Aris
4
+ module Plugins
5
+ class Json
6
+ PARSEABLE_METHODS = %w[POST PUT PATCH].freeze
7
+
8
+ def self.call(request, response)
9
+ return nil unless PARSEABLE_METHODS.include?(request.method)
10
+
11
+ raw_body = request.body
12
+ return nil if raw_body.nil? || raw_body.empty?
13
+
14
+ begin
15
+ data = JSON.parse(raw_body)
16
+ # Attach parsed data to request
17
+ request.json_body = data
18
+ rescue JSON::ParserError => e
19
+ response.status = 400
20
+ response.headers['content-type'] = 'application/json'
21
+ response.body = [JSON.generate({ error: 'Invalid JSON', message: e.message })]
22
+ return response # Halt pipeline
23
+ end
24
+
25
+ nil # Continue pipeline
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ # Self-register
32
+ Aris.register_plugin(:json, plugin_class: Aris::Plugins::Json)
@@ -0,0 +1,160 @@
1
+ # lib/aris/plugins/multipart.rb
2
+ require 'tempfile'
3
+ require 'securerandom'
4
+
5
+ module Aris
6
+ module Plugins
7
+ class Multipart
8
+ attr_reader :config
9
+
10
+ PARSEABLE_METHODS = %w[POST PUT PATCH].freeze
11
+
12
+ def initialize(**config)
13
+ @config = config
14
+ @max_file_size = config[:max_file_size] || 10_485_760 # 10MB default
15
+ @max_files = config[:max_files] || 10
16
+ @allowed_extensions = config[:allowed_extensions] # nil = all allowed
17
+ end
18
+
19
+ def call(request, response)
20
+ return nil unless PARSEABLE_METHODS.include?(request.method)
21
+
22
+ content_type = request.env['CONTENT_TYPE']
23
+ return nil unless content_type&.include?('multipart/form-data')
24
+
25
+ # Extract boundary from content type
26
+ boundary = extract_boundary(content_type)
27
+ unless boundary
28
+ response.status = 400
29
+ response.headers['content-type'] = 'text/plain'
30
+ response.body = ['Missing boundary in multipart request']
31
+ return response
32
+ end
33
+
34
+ raw_body = request.body
35
+ return nil if raw_body.nil? || raw_body.empty?
36
+
37
+ begin
38
+ # Parse multipart data
39
+ parts = parse_multipart(raw_body, boundary)
40
+
41
+ # Validate file count
42
+ files = parts.select { |p| p[:filename] }
43
+ if files.size > @max_files
44
+ response.status = 413
45
+ response.headers['content-type'] = 'text/plain'
46
+ response.body = ["Too many files (max #{@max_files})"]
47
+ return response
48
+ end
49
+
50
+ # Validate file sizes and extensions
51
+ files.each do |file|
52
+ if file[:data].bytesize > @max_file_size
53
+ response.status = 413
54
+ response.headers['content-type'] = 'text/plain'
55
+ response.body = ["File '#{file[:filename]}' exceeds maximum size (#{@max_file_size} bytes)"]
56
+ return response
57
+ end
58
+
59
+ if @allowed_extensions
60
+ ext = File.extname(file[:filename]).downcase
61
+ unless @allowed_extensions.include?(ext)
62
+ response.status = 400
63
+ response.headers['content-type'] = 'text/plain'
64
+ response.body = ["File type '#{ext}' not allowed"]
65
+ return response
66
+ end
67
+ end
68
+ end
69
+
70
+ # Attach parsed data to request
71
+ request.instance_variable_set(:@multipart_data, parts)
72
+
73
+ rescue => e
74
+ response.status = 400
75
+ response.headers['content-type'] = 'text/plain'
76
+ response.body = ['Invalid multipart data']
77
+ return response
78
+ end
79
+
80
+ nil # Continue pipeline
81
+ end
82
+
83
+ def self.build(**config)
84
+ new(**config)
85
+ end
86
+
87
+ private
88
+
89
+ def extract_boundary(content_type)
90
+ match = content_type.match(/boundary=(?:"([^"]+)"|([^;]+))/)
91
+ match ? (match[1] || match[2]) : nil
92
+ end
93
+
94
+ def parse_multipart(body, boundary)
95
+ parts = []
96
+ delimiter = "--#{boundary}"
97
+
98
+ # Split by boundary
99
+ sections = body.split(delimiter)
100
+
101
+ # Skip first (empty) and last (closing) sections
102
+ sections[1..-2]&.each do |section|
103
+ next if section.strip.empty?
104
+
105
+ # Split headers from content
106
+ header_end = section.index("\r\n\r\n") || section.index("\n\n")
107
+ next unless header_end
108
+
109
+ headers_raw = section[0...header_end]
110
+ content = section[(header_end + 4)..-1]
111
+
112
+ # Remove trailing CRLF
113
+ content = content.chomp("\r\n").chomp("\n")
114
+
115
+ # Parse Content-Disposition header
116
+ disposition = headers_raw.match(/Content-Disposition:\s*(.+?)(?:\r?\n|$)/i)
117
+ next unless disposition
118
+
119
+ disposition_value = disposition[1]
120
+
121
+ # Extract field name
122
+ name_match = disposition_value.match(/name="([^"]+)"/)
123
+ name = name_match ? name_match[1] : nil
124
+ next unless name
125
+
126
+ # Extract filename if present (indicates file upload)
127
+ filename_match = disposition_value.match(/filename="([^"]+)"/)
128
+ filename = filename_match ? filename_match[1] : nil
129
+
130
+ # Extract content type if present
131
+ content_type_match = headers_raw.match(/content-type:\s*(.+?)(?:\r?\n|$)/i)
132
+ content_type = content_type_match ? content_type_match[1].strip : nil
133
+
134
+ if filename
135
+ # File upload
136
+ parts << {
137
+ name: name,
138
+ filename: filename,
139
+ content_type: content_type || 'application/octet-stream',
140
+ data: content,
141
+ type: :file
142
+ }
143
+ else
144
+ # Regular form field
145
+ parts << {
146
+ name: name,
147
+ data: content,
148
+ type: :field
149
+ }
150
+ end
151
+ end
152
+
153
+ parts
154
+ end
155
+ end
156
+ end
157
+ end
158
+
159
+ # Self-register
160
+ Aris.register_plugin(:multipart, plugin_class: Aris::Plugins::Multipart)
@@ -0,0 +1,60 @@
1
+ module Aris
2
+ module Plugins
3
+ class RateLimiter
4
+ LIMIT_WINDOW = 60
5
+
6
+ # In-memory store (replace with Redis in production)
7
+ @@store = {}
8
+ @@mutex = Mutex.new
9
+
10
+ def self.call(request, response)
11
+ key = request.headers['HTTP_X_API_KEY'] || request.host
12
+
13
+ if limit_exceeded?(key)
14
+ response.status = 429
15
+ response.headers['content-type'] = 'text/plain'
16
+ response.headers['Retry-After'] = LIMIT_WINDOW.to_s
17
+ response.body = ['Rate limit exceeded. Try again later.']
18
+ return response # Halt pipeline
19
+ end
20
+
21
+ increment_count(key)
22
+ nil # Continue pipeline
23
+ end
24
+
25
+ private
26
+
27
+ def self.limit_exceeded?(key)
28
+ @@mutex.synchronize do
29
+ entry = @@store[key]
30
+ return false unless entry
31
+
32
+ # Check if we're still in the window and over limit
33
+ entry[:count] >= 100 && (Time.now - entry[:window_start]) < LIMIT_WINDOW
34
+ end
35
+ end
36
+
37
+ def self.increment_count(key)
38
+ @@mutex.synchronize do
39
+ entry = @@store[key] ||= { count: 0, window_start: Time.now }
40
+
41
+ # Reset window if expired
42
+ if (Time.now - entry[:window_start]) >= LIMIT_WINDOW
43
+ entry[:count] = 0
44
+ entry[:window_start] = Time.now
45
+ end
46
+
47
+ entry[:count] += 1
48
+ end
49
+ end
50
+
51
+ # For testing: clear the store
52
+ def self.reset!
53
+ @@mutex.synchronize { @@store.clear }
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ # Self-register
60
+ Aris.register_plugin(:rate_limit, plugin_class: Aris::Plugins::RateLimiter)
@@ -0,0 +1,38 @@
1
+ # lib/aris/plugins/request_id.rb
2
+ require 'securerandom'
3
+
4
+ module Aris
5
+ module Plugins
6
+ class RequestId
7
+ attr_reader :config
8
+
9
+ def initialize(**config)
10
+ @config = config
11
+ @header_name = config[:header_name] || 'X-Request-ID'
12
+ @generator = config[:generator] || -> { SecureRandom.uuid }
13
+ end
14
+
15
+ def call(request, response)
16
+ request_id = request.headers["HTTP_#{header_to_env(@header_name)}"]
17
+ request_id ||= @generator.call
18
+ request.instance_variable_set(:@request_id, request_id)
19
+ response.headers[@header_name] = request_id
20
+
21
+ nil # Continue pipeline
22
+ end
23
+
24
+ def self.build(**config)
25
+ new(**config)
26
+ end
27
+
28
+ private
29
+
30
+ def header_to_env(header_name)
31
+ # Convert X-Request-ID → X_REQUEST_ID
32
+ header_name.upcase.gsub('-', '_')
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ Aris.register_plugin(:request_id, plugin_class: Aris::Plugins::RequestId)
@@ -0,0 +1,43 @@
1
+ # lib/aris/plugins/request_logger.rb
2
+ require 'json'
3
+ require 'logger'
4
+
5
+ module Aris
6
+ module Plugins
7
+ class RequestLogger
8
+ attr_reader :config
9
+
10
+ def initialize(**config)
11
+ @config = config
12
+ @format = config[:format] || :text
13
+ @exclude = Array(config[:exclude] || [])
14
+ @logger = config[:logger] || ::Logger.new(STDOUT)
15
+ end
16
+
17
+ def call(request, response)
18
+ # Skip excluded paths
19
+ return nil if @exclude.include?(request.path)
20
+
21
+ # Log the request
22
+ entry = {
23
+ method: request.method,
24
+ path: request.path,
25
+ host: request.host,
26
+ timestamp: Time.now.iso8601
27
+ }
28
+
29
+ if @format == :json
30
+ @logger.info(JSON.generate(entry))
31
+ else
32
+ @logger.info("#{entry[:method]} #{entry[:path]}")
33
+ end
34
+
35
+ nil # Continue pipeline
36
+ end
37
+
38
+ def self.build(**config)
39
+ new(**config)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,99 @@
1
+ # security_headers.rb
2
+
3
+ module Aris
4
+ module Plugins
5
+ class SecurityHeaders
6
+ attr_reader :config
7
+
8
+ # Default secure headers
9
+ DEFAULTS = {
10
+ 'X-Frame-Options' => 'SAMEORIGIN',
11
+ 'X-content-type-Options' => 'nosniff',
12
+ 'X-XSS-Protection' => '0', # Modern browsers ignore this, disabled preferred
13
+ 'Referrer-Policy' => 'strict-origin-when-cross-origin'
14
+ }.freeze
15
+
16
+ def initialize(**config)
17
+ @config = config
18
+ @headers = build_headers(config)
19
+ end
20
+
21
+ def call(request, response)
22
+ # Set all configured headers
23
+ @headers.each do |key, value|
24
+ response.headers[key] = value
25
+ end
26
+
27
+ nil # Continue pipeline
28
+ end
29
+
30
+ def self.build(**config)
31
+ new(**config)
32
+ end
33
+
34
+ private
35
+
36
+ def build_headers(config)
37
+ headers = {}
38
+
39
+ # Start with defaults unless disabled
40
+ unless config[:defaults] == false
41
+ headers.merge!(DEFAULTS)
42
+ end
43
+
44
+ # X-Frame-Options
45
+ if config.key?(:x_frame_options)
46
+ if config[:x_frame_options]
47
+ headers['X-Frame-Options'] = config[:x_frame_options]
48
+ else
49
+ headers.delete('X-Frame-Options') # ✅ Explicitly remove when nil
50
+ end
51
+ end
52
+
53
+ # X-content-type-Options
54
+ if config.key?(:x_content_type_options)
55
+ if config[:x_content_type_options]
56
+ headers['X-content-type-Options'] = config[:x_content_type_options]
57
+ else
58
+ headers.delete('X-content-type-Options') # ✅ Explicitly remove when nil
59
+ end
60
+ end
61
+
62
+ # Referrer-Policy
63
+ if config.key?(:referrer_policy)
64
+ if config[:referrer_policy]
65
+ headers['Referrer-Policy'] = config[:referrer_policy]
66
+ else
67
+ headers.delete('Referrer-Policy') # ✅ Explicitly remove when nil
68
+ end
69
+ end
70
+
71
+ # HSTS (same as before)
72
+ if config[:hsts]
73
+ hsts_value = if config[:hsts].is_a?(Hash)
74
+ max_age = config[:hsts][:max_age] || 31536000
75
+ directives = ["max-age=#{max_age}"]
76
+ directives << 'includeSubDomains' if config[:hsts][:include_subdomains]
77
+ directives << 'preload' if config[:hsts][:preload]
78
+ directives.join('; ')
79
+ else
80
+ 'max-age=31536000; includeSubDomains'
81
+ end
82
+ headers['Strict-Transport-Security'] = hsts_value
83
+ end
84
+
85
+ # CSP (same as before)
86
+ if config[:csp]
87
+ headers['Content-Security-Policy'] = config[:csp]
88
+ end
89
+
90
+ # Permissions-Policy (same as before)
91
+ if config[:permissions_policy]
92
+ headers['Permissions-Policy'] = config[:permissions_policy]
93
+ end
94
+
95
+ headers
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,175 @@
1
+ # lib/aris/plugins/session.rb
2
+ require 'json'
3
+ require 'base64'
4
+ require 'openssl'
5
+
6
+ module Aris
7
+ module Plugins
8
+ class Session
9
+ @default_config = {
10
+ enabled: true,
11
+ store: :cookie,
12
+ key: '_aris_session',
13
+ expire_after: 14 * 24 * 3600, # 2 weeks in seconds
14
+ secret: nil
15
+ }
16
+
17
+ class << self
18
+ attr_accessor :default_config
19
+
20
+ def call(request, response)
21
+ load_session(request)
22
+ nil
23
+ end
24
+
25
+ def call_response(request, response)
26
+ return unless request.respond_to?(:session)
27
+
28
+ store_session(request, response)
29
+ end
30
+
31
+ def build(**config)
32
+ config = default_config.merge(config)
33
+ config[:secret] ||= Aris::Config.secret_key_base
34
+ new(config)
35
+ end
36
+
37
+ private
38
+
39
+ def load_session(request)
40
+ session_data = load_from_store(request)
41
+
42
+ request.define_singleton_method(:session) do
43
+ @session ||= SessionData.new(session_data)
44
+ end
45
+ end
46
+
47
+ def store_session(request, response)
48
+ return unless request.session.changed? || request.session.destroyed?
49
+
50
+ if request.session.destroyed?
51
+ clear_from_store(request, response)
52
+ else
53
+ save_to_store(request, response, request.session.to_hash)
54
+ end
55
+ end
56
+
57
+ def load_from_store(request)
58
+ case default_config[:store]
59
+ when :cookie
60
+ load_from_cookie(request)
61
+ else
62
+ {} # Default empty session
63
+ end
64
+ end
65
+
66
+ def save_to_store(request, response, data)
67
+ case default_config[:store]
68
+ when :cookie
69
+ save_to_cookie(request, response, data)
70
+ end
71
+ end
72
+
73
+ def clear_from_store(request, response)
74
+ case default_config[:store]
75
+ when :cookie
76
+ clear_cookie(response)
77
+ end
78
+ end
79
+
80
+ def load_from_cookie(request)
81
+ return {} unless request.respond_to?(:cookies)
82
+
83
+ cookie_value = request.cookies[default_config[:key]]
84
+ return {} unless cookie_value
85
+
86
+ begin
87
+ # For encrypted sessions
88
+ decrypt_session(cookie_value)
89
+ rescue
90
+ {} # Invalid session, start fresh
91
+ end
92
+ end
93
+
94
+ def save_to_cookie(request, response, data)
95
+ return if data.empty?
96
+
97
+ encrypted_data = encrypt_session(data)
98
+ response.set_cookie(default_config[:key], encrypted_data, {
99
+ httponly: true,
100
+ secure: (ENV['RACK_ENV'] == 'production'),
101
+ path: '/',
102
+ max_age: default_config[:expire_after]
103
+ })
104
+ end
105
+
106
+ def clear_cookie(response)
107
+ response.delete_cookie(default_config[:key])
108
+ end
109
+
110
+ def encrypt_session(data)
111
+ # Simple encryption for demo - use proper encryption in production
112
+ json_data = data.to_json
113
+ Base64.urlsafe_encode64(json_data)
114
+ end
115
+
116
+ def decrypt_session(encrypted_data)
117
+ json_data = Base64.urlsafe_decode64(encrypted_data)
118
+ JSON.parse(json_data, symbolize_names: true)
119
+ end
120
+ end
121
+
122
+ def initialize(config)
123
+ @config = config
124
+ end
125
+
126
+ # Session data container
127
+ class SessionData
128
+ def initialize(initial_data = {})
129
+ @data = initial_data || {}
130
+ @changed = false
131
+ @destroyed = false
132
+ end
133
+
134
+ def [](key)
135
+ @data[key.to_sym]
136
+ end
137
+
138
+ def []=(key, value)
139
+ @data[key.to_sym] = value
140
+ @changed = true
141
+ end
142
+
143
+ def delete(key)
144
+ @data.delete(key.to_sym)
145
+ @changed = true
146
+ end
147
+
148
+ def clear
149
+ @data.clear
150
+ @changed = true
151
+ end
152
+
153
+ def destroy
154
+ clear
155
+ @destroyed = true
156
+ end
157
+
158
+ def to_hash
159
+ @data.dup
160
+ end
161
+
162
+ def changed?
163
+ @changed
164
+ end
165
+
166
+ def destroyed?
167
+ @destroyed
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ # Register the plugin
175
+ Aris.register_plugin(:session, plugin_class: Aris::Plugins::Session)
@@ -0,0 +1,23 @@
1
+ module Aris
2
+ module Plugins
3
+ @@registry = {}
4
+
5
+ def self.register(name, *classes)
6
+ raise ArgumentError, "Plugin '#{name}' requires at least one class" if classes.empty?
7
+ @@registry[name.to_sym] = classes.flatten
8
+ end
9
+
10
+ def self.resolve(name)
11
+ @@registry[name.to_sym] || raise(ArgumentError, "Unknown plugin :#{name}")
12
+ end
13
+ end
14
+
15
+ def self.register_plugin(name, **options)
16
+ classes = [options[:generator], options[:protection], options[:plugin_class]].flatten.compact
17
+ Plugins.register(name, *classes)
18
+ end
19
+
20
+ def self.resolve_plugin(name)
21
+ Plugins.resolve(name)
22
+ end
23
+ end