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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +342 -0
- data/lib/aris/adapters/base.rb +25 -0
- data/lib/aris/adapters/joys_integration.rb +94 -0
- data/lib/aris/adapters/mock/adapter.rb +141 -0
- data/lib/aris/adapters/mock/request.rb +81 -0
- data/lib/aris/adapters/mock/response.rb +17 -0
- data/lib/aris/adapters/rack/adapter.rb +117 -0
- data/lib/aris/adapters/rack/request.rb +66 -0
- data/lib/aris/adapters/rack/response.rb +16 -0
- data/lib/aris/core.rb +931 -0
- data/lib/aris/discovery.rb +312 -0
- data/lib/aris/locale_injector.rb +39 -0
- data/lib/aris/pipeline_runner.rb +100 -0
- data/lib/aris/plugins/api_key_auth.rb +61 -0
- data/lib/aris/plugins/basic_auth.rb +68 -0
- data/lib/aris/plugins/bearer_auth.rb +64 -0
- data/lib/aris/plugins/cache.rb +120 -0
- data/lib/aris/plugins/compression.rb +96 -0
- data/lib/aris/plugins/cookies.rb +46 -0
- data/lib/aris/plugins/cors.rb +81 -0
- data/lib/aris/plugins/csrf.rb +48 -0
- data/lib/aris/plugins/etag.rb +90 -0
- data/lib/aris/plugins/flash.rb +124 -0
- data/lib/aris/plugins/form_parser.rb +46 -0
- data/lib/aris/plugins/health_check.rb +62 -0
- data/lib/aris/plugins/json.rb +32 -0
- data/lib/aris/plugins/multipart.rb +160 -0
- data/lib/aris/plugins/rate_limiter.rb +60 -0
- data/lib/aris/plugins/request_id.rb +38 -0
- data/lib/aris/plugins/request_logger.rb +43 -0
- data/lib/aris/plugins/security_headers.rb +99 -0
- data/lib/aris/plugins/session.rb +175 -0
- data/lib/aris/plugins.rb +23 -0
- data/lib/aris/response_helpers.rb +156 -0
- data/lib/aris/route_helpers.rb +141 -0
- data/lib/aris/utils/redirects.rb +44 -0
- data/lib/aris/utils/sitemap.rb +84 -0
- data/lib/aris/version.rb +3 -0
- data/lib/aris.rb +35 -0
- metadata +151 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# lib/aris/discovery.rb
|
|
2
|
+
require 'pathname'
|
|
3
|
+
|
|
4
|
+
module Aris
|
|
5
|
+
module Discovery
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
HTTP_METHODS = Set[:get, :post, :put, :patch, :delete, :options].freeze
|
|
9
|
+
|
|
10
|
+
# Scans a directory and generates an Aris routes hash with locale support
|
|
11
|
+
# Handlers are LOADED during discovery, not at request time
|
|
12
|
+
def discover(routes_dir, namespace_handlers: true)
|
|
13
|
+
base_path = Pathname.new(routes_dir).realpath
|
|
14
|
+
routes = {}
|
|
15
|
+
domain_configs = {}
|
|
16
|
+
|
|
17
|
+
# First pass: discover domain configs
|
|
18
|
+
Dir.glob("#{routes_dir}/*/_config.rb").sort.each do |config_file|
|
|
19
|
+
domain_name = File.basename(File.dirname(config_file))
|
|
20
|
+
domain_key = (domain_name == '_') ? '*' : domain_name
|
|
21
|
+
|
|
22
|
+
config = discover_domain_config(config_file)
|
|
23
|
+
domain_configs[domain_key] = config if config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Second pass: discover route files
|
|
27
|
+
Dir.glob("#{routes_dir}/**/*.rb").sort.each do |file_path|
|
|
28
|
+
next if file_path.end_with?('_config.rb')
|
|
29
|
+
|
|
30
|
+
route_info = parse_route_file(file_path, base_path)
|
|
31
|
+
next unless route_info
|
|
32
|
+
|
|
33
|
+
# Load the handler NOW (at boot time, not request time)
|
|
34
|
+
handler = load_handler(file_path, route_info, namespace_handlers)
|
|
35
|
+
next unless handler
|
|
36
|
+
|
|
37
|
+
# Check for localized declaration
|
|
38
|
+
localized_paths = discover_handler_locales(handler)
|
|
39
|
+
|
|
40
|
+
# If handler has localized declaration, validate against domain config
|
|
41
|
+
if localized_paths && !localized_paths.empty?
|
|
42
|
+
domain_config = domain_configs[route_info[:domain]]
|
|
43
|
+
|
|
44
|
+
if domain_config
|
|
45
|
+
validate_localized_handler(
|
|
46
|
+
handler_path: File.dirname(file_path),
|
|
47
|
+
localized_paths: localized_paths,
|
|
48
|
+
domain_config: domain_config,
|
|
49
|
+
route_info: route_info
|
|
50
|
+
)
|
|
51
|
+
else
|
|
52
|
+
warn "Warning: Handler at #{file_path} declares localized paths but domain has no _config.rb"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build the nested route structure
|
|
57
|
+
add_route_to_hash(routes, route_info, handler, localized_paths)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Merge domain configs into routes
|
|
61
|
+
domain_configs.each do |domain, config|
|
|
62
|
+
routes[domain] ||= {}
|
|
63
|
+
routes[domain][:locales] = config[:locales]
|
|
64
|
+
routes[domain][:default_locale] = config[:default_locale]
|
|
65
|
+
routes[domain][:root_locale_redirect] = config[:root_locale_redirect] if config.key?(:root_locale_redirect)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
routes
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Discover domain configuration from _config.rb file
|
|
74
|
+
def discover_domain_config(config_file)
|
|
75
|
+
load config_file
|
|
76
|
+
|
|
77
|
+
# Look for DomainConfig module
|
|
78
|
+
if defined?(DomainConfig)
|
|
79
|
+
config = {
|
|
80
|
+
locales: DomainConfig::LOCALES,
|
|
81
|
+
default_locale: DomainConfig::DEFAULT_LOCALE
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Optional root redirect configuration
|
|
85
|
+
if DomainConfig.const_defined?(:ROOT_LOCALE_REDIRECT)
|
|
86
|
+
config[:root_locale_redirect] = DomainConfig::ROOT_LOCALE_REDIRECT
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Clean up to avoid conflicts with next domain
|
|
90
|
+
Object.send(:remove_const, :DomainConfig)
|
|
91
|
+
|
|
92
|
+
return config
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
nil
|
|
96
|
+
rescue => e
|
|
97
|
+
warn "Error loading domain config from #{config_file}: #{e.message}"
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Parse file path into route information
|
|
102
|
+
def parse_route_file(file_path, base_path)
|
|
103
|
+
absolute_path = Pathname.new(file_path).realpath
|
|
104
|
+
relative_path = absolute_path.relative_path_from(base_path)
|
|
105
|
+
parts = relative_path.to_s.split('/')
|
|
106
|
+
|
|
107
|
+
return nil if parts.size < 2 # Need at least domain/method.rb
|
|
108
|
+
|
|
109
|
+
# Extract method from filename
|
|
110
|
+
method_file = parts.pop
|
|
111
|
+
method_name = File.basename(method_file, '.rb').downcase.to_sym
|
|
112
|
+
return nil unless HTTP_METHODS.include?(method_name)
|
|
113
|
+
|
|
114
|
+
# Extract domain
|
|
115
|
+
domain_name = parts.shift
|
|
116
|
+
domain_key = (domain_name == '_') ? '*' : domain_name
|
|
117
|
+
|
|
118
|
+
# Build path segments, converting _param to :param
|
|
119
|
+
path_parts = parts.reject { |p| p == 'index' }
|
|
120
|
+
.map { |p| p.start_with?('_') ? ":#{p[1..]}" : "/#{p}" }
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
domain: domain_key,
|
|
124
|
+
path_parts: path_parts,
|
|
125
|
+
method: method_name,
|
|
126
|
+
file_path: absolute_path.to_s,
|
|
127
|
+
namespace: build_namespace(domain_key, path_parts, method_name)
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Build a namespace for the handler to avoid conflicts
|
|
132
|
+
# e.g., "example.com/users/:id", :get -> ExampleCom::Users::Id::Get
|
|
133
|
+
def build_namespace(domain, path_parts, method)
|
|
134
|
+
# Convert * to Wildcard for valid module name
|
|
135
|
+
domain_part = domain == '*' ? 'Wildcard' : domain.gsub(/[^a-zA-Z0-9]/, '_')
|
|
136
|
+
parts = [domain_part]
|
|
137
|
+
parts += path_parts.map do |p|
|
|
138
|
+
p.sub('/', '').sub(':', '').gsub(/[^a-zA-Z0-9]/, '_')
|
|
139
|
+
end
|
|
140
|
+
parts << method.to_s # Add method to ensure unique namespace
|
|
141
|
+
parts.map(&:capitalize).join('::')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Load handler from file and namespace it to avoid conflicts
|
|
145
|
+
def load_handler(file_path, route_info, namespace_handlers)
|
|
146
|
+
if namespace_handlers
|
|
147
|
+
# Create a module namespace for this specific route
|
|
148
|
+
namespace_module = create_namespace_module(route_info[:namespace])
|
|
149
|
+
|
|
150
|
+
# Evaluate the file within that namespace
|
|
151
|
+
code = File.read(file_path)
|
|
152
|
+
namespace_module.module_eval(code, file_path)
|
|
153
|
+
|
|
154
|
+
# Look for Handler constant in the namespace
|
|
155
|
+
if namespace_module.const_defined?(:Handler, false)
|
|
156
|
+
handler = namespace_module.const_get(:Handler)
|
|
157
|
+
validate_handler!(handler, file_path)
|
|
158
|
+
return handler
|
|
159
|
+
else
|
|
160
|
+
warn "Warning: #{file_path} does not define a Handler constant"
|
|
161
|
+
return nil
|
|
162
|
+
end
|
|
163
|
+
else
|
|
164
|
+
# Load file and grab top-level Handler (simpler but risks conflicts)
|
|
165
|
+
load file_path
|
|
166
|
+
|
|
167
|
+
if Object.const_defined?(:Handler, false)
|
|
168
|
+
handler = Object.const_get(:Handler)
|
|
169
|
+
Object.send(:remove_const, :Handler) # Clean up to avoid next file's conflict
|
|
170
|
+
validate_handler!(handler, file_path)
|
|
171
|
+
return handler
|
|
172
|
+
else
|
|
173
|
+
warn "Warning: #{file_path} does not define a Handler constant"
|
|
174
|
+
return nil
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
rescue SyntaxError => e
|
|
178
|
+
warn "Syntax error in #{file_path}: #{e.message}"
|
|
179
|
+
nil
|
|
180
|
+
rescue => e
|
|
181
|
+
warn "Error loading handler from #{file_path}: #{e.message}"
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Create nested module namespace
|
|
186
|
+
def create_namespace_module(namespace_string)
|
|
187
|
+
parts = namespace_string.split('::')
|
|
188
|
+
parts.reduce(Object) do |parent, part|
|
|
189
|
+
if parent.const_defined?(part, false)
|
|
190
|
+
parent.const_get(part)
|
|
191
|
+
else
|
|
192
|
+
parent.const_set(part, Module.new)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Validate handler has required interface
|
|
198
|
+
def validate_handler!(handler, file_path)
|
|
199
|
+
unless handler.respond_to?(:call)
|
|
200
|
+
raise ArgumentError,
|
|
201
|
+
"Handler in #{file_path} must respond to .call(request, params)"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Check if handler declares localized paths
|
|
206
|
+
def discover_handler_locales(handler)
|
|
207
|
+
if handler.respond_to?(:localized_paths)
|
|
208
|
+
handler.localized_paths
|
|
209
|
+
else
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Validate localized handler against domain config
|
|
215
|
+
def validate_localized_handler(handler_path:, localized_paths:, domain_config:, route_info:)
|
|
216
|
+
domain_locales = domain_config[:locales]
|
|
217
|
+
handler_locales = localized_paths.keys
|
|
218
|
+
|
|
219
|
+
# Error: handler uses locale not in domain config
|
|
220
|
+
invalid_locales = handler_locales - domain_locales
|
|
221
|
+
if invalid_locales.any?
|
|
222
|
+
raise Aris::Router::LocaleError,
|
|
223
|
+
"Handler at #{handler_path} uses locales #{invalid_locales.inspect} " +
|
|
224
|
+
"but domain only declares #{domain_locales.inspect}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Warning: handler missing some domain locales
|
|
228
|
+
missing_locales = domain_locales - handler_locales
|
|
229
|
+
if missing_locales.any?
|
|
230
|
+
warn "Warning: Handler at #{handler_path} missing locales: #{missing_locales.inspect}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Validate data files exist
|
|
234
|
+
validate_data_files(handler_path, handler_locales)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Validate data files exist for each locale
|
|
238
|
+
def validate_data_files(handler_path, locales)
|
|
239
|
+
locales.each do |locale|
|
|
240
|
+
found = [
|
|
241
|
+
"data_#{locale}.rb",
|
|
242
|
+
"data_#{locale}.json",
|
|
243
|
+
"data_#{locale}.yml",
|
|
244
|
+
"data_#{locale}.yaml",
|
|
245
|
+
"data/#{locale}.json",
|
|
246
|
+
"data/#{locale}.yml",
|
|
247
|
+
"data/#{locale}.yaml"
|
|
248
|
+
].any? { |f| File.exist?(File.join(handler_path, f)) }
|
|
249
|
+
|
|
250
|
+
unless found
|
|
251
|
+
warn "Warning: Missing data file for locale :#{locale} in #{handler_path}"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Add route to nested hash structure
|
|
257
|
+
def add_route_to_hash(routes, route_info, handler, localized_paths)
|
|
258
|
+
domain = route_info[:domain]
|
|
259
|
+
routes[domain] ||= {}
|
|
260
|
+
|
|
261
|
+
current_level = routes[domain]
|
|
262
|
+
|
|
263
|
+
# Navigate/create nested path structure
|
|
264
|
+
route_info[:path_parts].each do |part|
|
|
265
|
+
current_level[part] ||= {}
|
|
266
|
+
current_level = current_level[part]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Handle root path (no path parts)
|
|
270
|
+
if route_info[:path_parts].empty?
|
|
271
|
+
current_level['/'] ||= {}
|
|
272
|
+
current_level = current_level['/']
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Build route definition
|
|
276
|
+
route_def = { to: handler }
|
|
277
|
+
|
|
278
|
+
# Add localized paths if declared
|
|
279
|
+
if localized_paths && !localized_paths.empty?
|
|
280
|
+
route_def[:localized] = localized_paths
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Add the route
|
|
284
|
+
current_level[route_info[:method]] = route_def
|
|
285
|
+
|
|
286
|
+
# Now register metadata AFTER route is added
|
|
287
|
+
if defined?(Aris::Utils::Sitemap) && handler.respond_to?(:sitemap_metadata) && handler.sitemap_metadata
|
|
288
|
+
path = route_info[:path_parts].empty? ? '/' : route_info[:path_parts].join('')
|
|
289
|
+
Aris::Utils::Sitemap.register(
|
|
290
|
+
domain: route_info[:domain],
|
|
291
|
+
path: path,
|
|
292
|
+
method: route_info[:method],
|
|
293
|
+
metadata: handler.sitemap_metadata
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
if defined?(Aris::Utils::Redirects) && handler.respond_to?(:redirect_metadata) && handler.redirect_metadata
|
|
298
|
+
path = route_info[:path_parts].empty? ? '/' : route_info[:path_parts].join('')
|
|
299
|
+
Aris::Utils::Redirects.register(
|
|
300
|
+
from_paths: handler.redirect_metadata[:paths],
|
|
301
|
+
to_path: path,
|
|
302
|
+
status: handler.redirect_metadata[:status]
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def self.discover_and_define(routes_dir, namespace_handlers: true)
|
|
309
|
+
discovered = Discovery.discover(routes_dir, namespace_handlers: namespace_handlers)
|
|
310
|
+
self.routes(discovered, from_discovery: true)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# lib/aris/locale_injector.rb
|
|
2
|
+
module Aris
|
|
3
|
+
module LocaleInjector
|
|
4
|
+
extend self
|
|
5
|
+
|
|
6
|
+
# Inject locale-aware methods into request object after route matching
|
|
7
|
+
# @param request [Object] The request object (Rack::Request, Mock::Request, etc.)
|
|
8
|
+
# @param match_result [Hash] The route match result containing :locale, :domain
|
|
9
|
+
def inject_locale_methods(request, match_result)
|
|
10
|
+
return unless match_result && match_result[:locale]
|
|
11
|
+
|
|
12
|
+
locale = match_result[:locale]
|
|
13
|
+
domain = match_result[:domain]
|
|
14
|
+
domain_config = Aris::Router.domain_config(domain)
|
|
15
|
+
|
|
16
|
+
return unless domain_config
|
|
17
|
+
|
|
18
|
+
# Inject locale information methods
|
|
19
|
+
request.define_singleton_method(:locale) { locale }
|
|
20
|
+
request.define_singleton_method(:available_locales) { domain_config[:locales] }
|
|
21
|
+
request.define_singleton_method(:default_locale) { domain_config[:default_locale] }
|
|
22
|
+
request.define_singleton_method(:domain_config) { domain_config }
|
|
23
|
+
|
|
24
|
+
# Inject locale-aware path generation
|
|
25
|
+
request.define_singleton_method(:path_for) do |name, **opts|
|
|
26
|
+
opts[:locale] ||= locale
|
|
27
|
+
opts[:domain] ||= domain
|
|
28
|
+
Aris.path(name, **opts)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Inject locale-aware URL generation
|
|
32
|
+
request.define_singleton_method(:url_for) do |name, **opts|
|
|
33
|
+
opts[:locale] ||= locale
|
|
34
|
+
opts[:domain] ||= domain
|
|
35
|
+
Aris.url(name, **opts)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module Aris
|
|
2
|
+
module PipelineRunner
|
|
3
|
+
extend self
|
|
4
|
+
|
|
5
|
+
def call(request:, route:, response:)
|
|
6
|
+
|
|
7
|
+
if route[:subdomain]
|
|
8
|
+
request.define_singleton_method(:subdomain) { route[:subdomain] }
|
|
9
|
+
route[:params] ||= {}
|
|
10
|
+
route[:params][:subdomain] = route[:subdomain]
|
|
11
|
+
end
|
|
12
|
+
if route[:use] && !route[:use].empty?
|
|
13
|
+
route[:use].each do |plugin|
|
|
14
|
+
result = plugin.call(request, response)
|
|
15
|
+
if result.is_a?(Array) || (result.respond_to?(:status) && result.respond_to?(:headers) && result.respond_to?(:body))
|
|
16
|
+
return result # Plugin halted pipeline
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
handler = route[:handler]
|
|
22
|
+
params = route[:params]
|
|
23
|
+
result = execute_handler(handler, request, params, response)
|
|
24
|
+
result = format_handler_result(result, response)
|
|
25
|
+
|
|
26
|
+
if route[:use] && !route[:use].empty?
|
|
27
|
+
route[:use].each do |plugin|
|
|
28
|
+
if plugin.respond_to?(:call_response)
|
|
29
|
+
plugin.call_response(request, result)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def execute_handler(handler, request, params, response)
|
|
40
|
+
case handler
|
|
41
|
+
when Proc, Method
|
|
42
|
+
if handler.parameters.length >= 3
|
|
43
|
+
handler.call(request, response, params)
|
|
44
|
+
else
|
|
45
|
+
handler.call(request, params)
|
|
46
|
+
end
|
|
47
|
+
when String
|
|
48
|
+
controller_name, action = handler.split('#')
|
|
49
|
+
controller_class = Object.const_get(controller_name)
|
|
50
|
+
controller = controller_class.new
|
|
51
|
+
controller.send(action, request, params)
|
|
52
|
+
else
|
|
53
|
+
if handler.respond_to?(:call)
|
|
54
|
+
method_obj = handler.method(:call)
|
|
55
|
+
if method_obj.parameters.length >= 3
|
|
56
|
+
handler.call(request, response, params)
|
|
57
|
+
else
|
|
58
|
+
handler.call(request, params)
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError, "Handler doesn't respond to call: #{handler.inspect}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def format_handler_result(result, response)
|
|
67
|
+
case result
|
|
68
|
+
when Array
|
|
69
|
+
# Rack array [status, headers, body] - convert to response object
|
|
70
|
+
response.status = result[0]
|
|
71
|
+
response.headers.merge!(result[1])
|
|
72
|
+
response.body = result[2]
|
|
73
|
+
response
|
|
74
|
+
when Hash
|
|
75
|
+
# JSON response
|
|
76
|
+
response.status = 200
|
|
77
|
+
response.headers['content-type'] = 'application/json'
|
|
78
|
+
response.body = [result.to_json]
|
|
79
|
+
response
|
|
80
|
+
when String
|
|
81
|
+
# Plain text response
|
|
82
|
+
response.status = 200
|
|
83
|
+
response.headers['content-type'] = 'text/plain'
|
|
84
|
+
response.body = [result]
|
|
85
|
+
response
|
|
86
|
+
else
|
|
87
|
+
# Already a response object (or response-like)
|
|
88
|
+
if result.respond_to?(:status) && result.respond_to?(:headers) && result.respond_to?(:body)
|
|
89
|
+
result
|
|
90
|
+
else
|
|
91
|
+
# Treat as string
|
|
92
|
+
response.status = 200
|
|
93
|
+
response.headers['content-type'] = 'text/plain'
|
|
94
|
+
response.body = [result.to_s]
|
|
95
|
+
response
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# lib/aris/plugins/api_key_auth.rb
|
|
2
|
+
|
|
3
|
+
module Aris
|
|
4
|
+
module Plugins
|
|
5
|
+
class ApiKeyAuth
|
|
6
|
+
attr_reader :config
|
|
7
|
+
|
|
8
|
+
def initialize(**config)
|
|
9
|
+
@config = config
|
|
10
|
+
@header = config[:header] || 'X-API-Key'
|
|
11
|
+
@realm = config[:realm] || 'API'
|
|
12
|
+
|
|
13
|
+
# Validate config
|
|
14
|
+
if config[:validator]
|
|
15
|
+
@validator = config[:validator]
|
|
16
|
+
elsif config[:key]
|
|
17
|
+
@validator = ->(k) { k == config[:key] }
|
|
18
|
+
elsif config[:keys]
|
|
19
|
+
valid_keys = Array(config[:keys])
|
|
20
|
+
@validator = ->(k) { valid_keys.include?(k) }
|
|
21
|
+
else
|
|
22
|
+
raise ArgumentError, "ApiKeyAuth requires :validator, :key, or :keys"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(request, response)
|
|
27
|
+
# Extract key from header
|
|
28
|
+
header_key = "HTTP_#{@header.upcase.gsub('-', '_')}"
|
|
29
|
+
api_key = request.headers[header_key]
|
|
30
|
+
|
|
31
|
+
unless api_key && !api_key.empty?
|
|
32
|
+
return unauthorized_response(response, 'Missing API key')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
unless @validator.call(api_key)
|
|
36
|
+
return unauthorized_response(response, 'Invalid API key')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Attach key to request for handlers
|
|
40
|
+
request.instance_variable_set(:@api_key, api_key)
|
|
41
|
+
nil # Continue pipeline
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.build(**config)
|
|
45
|
+
new(**config)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def unauthorized_response(response, message)
|
|
51
|
+
response.status = 401
|
|
52
|
+
response.headers['content-type'] = 'application/json'
|
|
53
|
+
response.headers['WWW-Authenticate'] = %(ApiKey realm="#{@realm}")
|
|
54
|
+
response.body = [JSON.generate({ error: 'Unauthorized', message: message })]
|
|
55
|
+
response
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Aris.register_plugin(:api_key, plugin_class: Aris::Plugins::ApiKeyAuth)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# lib/aris/plugins/basic_auth.rb
|
|
2
|
+
require 'base64'
|
|
3
|
+
|
|
4
|
+
module Aris
|
|
5
|
+
module Plugins
|
|
6
|
+
class BasicAuth
|
|
7
|
+
attr_reader :config
|
|
8
|
+
|
|
9
|
+
def initialize(**config)
|
|
10
|
+
@config = config
|
|
11
|
+
@realm = config[:realm] || 'Restricted Area'
|
|
12
|
+
|
|
13
|
+
# Validate config
|
|
14
|
+
if config[:validator]
|
|
15
|
+
@validator = config[:validator]
|
|
16
|
+
elsif config[:username] && config[:password]
|
|
17
|
+
@validator = ->(u, p) { u == config[:username] && p == config[:password] }
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, "BasicAuth requires either :validator or both :username and :password"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(request, response)
|
|
24
|
+
auth_header = request.headers['HTTP_AUTHORIZATION']
|
|
25
|
+
|
|
26
|
+
unless auth_header && auth_header.start_with?('Basic ')
|
|
27
|
+
return unauthorized_response(response, 'Missing or invalid Authorization header')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
username, password = decode_credentials(auth_header)
|
|
31
|
+
|
|
32
|
+
unless username && password
|
|
33
|
+
return unauthorized_response(response, 'Invalid credentials format')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless @validator.call(username, password)
|
|
37
|
+
return unauthorized_response(response, 'Invalid username or password')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Attach username to request for handlers
|
|
41
|
+
request.instance_variable_set(:@current_user, username)
|
|
42
|
+
nil # Continue pipeline
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.build(**config)
|
|
46
|
+
new(**config)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def decode_credentials(auth_header)
|
|
52
|
+
encoded = auth_header.sub('Basic ', '')
|
|
53
|
+
decoded = Base64.decode64(encoded)
|
|
54
|
+
decoded.split(':', 2)
|
|
55
|
+
rescue => e
|
|
56
|
+
[nil, nil]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def unauthorized_response(response, message)
|
|
60
|
+
response.status = 401
|
|
61
|
+
response.headers['content-type'] = 'text/plain'
|
|
62
|
+
response.headers['WWW-Authenticate'] = %(Basic realm="#{@realm}")
|
|
63
|
+
response.body = [message]
|
|
64
|
+
response
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# lib/aris/plugins/bearer_auth.rb
|
|
2
|
+
|
|
3
|
+
module Aris
|
|
4
|
+
module Plugins
|
|
5
|
+
class BearerAuth
|
|
6
|
+
attr_reader :config
|
|
7
|
+
|
|
8
|
+
def initialize(**config)
|
|
9
|
+
@config = config
|
|
10
|
+
@realm = config[:realm] || 'API'
|
|
11
|
+
|
|
12
|
+
# Validate config
|
|
13
|
+
if config[:validator]
|
|
14
|
+
@validator = config[:validator]
|
|
15
|
+
elsif config[:token]
|
|
16
|
+
@validator = ->(t) { t == config[:token] }
|
|
17
|
+
else
|
|
18
|
+
raise ArgumentError, "BearerAuth requires either :validator or :token"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(request, response)
|
|
23
|
+
auth_header = request.headers['HTTP_AUTHORIZATION']
|
|
24
|
+
|
|
25
|
+
unless auth_header && auth_header.start_with?('Bearer ')
|
|
26
|
+
return unauthorized_response(response, 'Missing or invalid Authorization header')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
token = extract_token(auth_header)
|
|
30
|
+
|
|
31
|
+
unless token && !token.empty?
|
|
32
|
+
return unauthorized_response(response, 'Invalid token format')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
unless @validator.call(token)
|
|
36
|
+
return unauthorized_response(response, 'Invalid or expired token')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Attach token to request for handlers
|
|
40
|
+
request.instance_variable_set(:@bearer_token, token)
|
|
41
|
+
nil # Continue pipeline
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.build(**config)
|
|
45
|
+
new(**config)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def extract_token(auth_header)
|
|
51
|
+
auth_header.sub('Bearer ', '').strip
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def unauthorized_response(response, message)
|
|
55
|
+
response.status = 401
|
|
56
|
+
response.headers['content-type'] = 'application/json'
|
|
57
|
+
response.headers['WWW-Authenticate'] = %(Bearer realm="#{@realm}")
|
|
58
|
+
response.body = [JSON.generate({ error: 'Unauthorized', message: message })]
|
|
59
|
+
response
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
Aris.register_plugin(:bearer_auth, plugin_class: Aris::Plugins::BearerAuth)
|