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
data/lib/aris/core.rb ADDED
@@ -0,0 +1,931 @@
1
+ require 'uri'
2
+ require 'set'
3
+
4
+ module Aris
5
+ @@default_not_found_handler = nil
6
+ @@default_error_handler = nil
7
+ module Config
8
+ class << self
9
+ attr_accessor :trailing_slash, :trailing_slash_status
10
+ attr_accessor :secret_key_base, :cookie_options
11
+ def reset!
12
+ @trailing_slash = :strict
13
+ @trailing_slash_status = 301
14
+ @secret_key_base = nil
15
+ @cookie_options = {
16
+ httponly: true,
17
+ secure: false, # Default to false for development
18
+ same_site: :lax,
19
+ path: '/'
20
+ }
21
+ end
22
+ end
23
+
24
+ reset!
25
+ end
26
+ def self.not_found(request, response = nil)
27
+ handler = @@default_not_found_handler
28
+ handler ? handler.call(request, {}) : [404, {'content-type' => 'text/plain'}, ['Not Found']]
29
+ end
30
+
31
+ def self.error(request, exception, response = nil)
32
+ handler = @@default_error_handler
33
+ return handler.call(request, exception) if handler
34
+ [500, {'content-type' => 'text/plain'}, ['Internal Server Error']]
35
+ rescue => e
36
+ # Handler itself failed - return fallback
37
+ [500, {'content-type' => 'text/plain'}, ['Internal Server Error']]
38
+ end
39
+ def self.configure
40
+ yield Config
41
+ end
42
+
43
+
44
+ module Router
45
+ extend self
46
+ HTTP_METHODS = Set[:get, :post, :put, :patch, :delete, :options].freeze
47
+
48
+ @@default_domain = nil
49
+ @@default_404_handler = nil
50
+ @@default_500_handler = nil
51
+
52
+
53
+ def default_domain=(domain)
54
+ @@default_domain = domain
55
+ end
56
+
57
+ def default_domain
58
+ @@default_domain
59
+ end
60
+
61
+ def set_defaults(config)
62
+ @@default_domain = config[:default_host] if config.key?(:default_host)
63
+ Aris.class_variable_set(:@@default_not_found_handler, config[:not_found]) if config.key?(:not_found)
64
+ Aris.class_variable_set(:@@default_error_handler, config[:error]) if config.key?(:error)
65
+ end
66
+
67
+ def define(config)
68
+ reset!
69
+ compile!(config)
70
+ self
71
+ end
72
+ def domain_config(domain)
73
+ @domain_configs ||= {}
74
+ @domain_configs[domain.to_s]
75
+ end
76
+
77
+ def store_domain_config(domain, config)
78
+ @domain_configs ||= {}
79
+ @domain_configs[domain.to_s] = config
80
+ end
81
+
82
+ def build_named_path(route_name, domain:, **params)
83
+ route_name = route_name.to_sym
84
+ locale = params.delete(:locale)
85
+ domain_config = self.domain_config(domain)
86
+
87
+ # If no locale specified but domain has default locale, use it
88
+ if !locale && domain_config && domain_config[:default_locale]
89
+ locale = domain_config[:default_locale]
90
+ end
91
+
92
+ # For localized routes, always use the base route name and let build_path handle it
93
+ found_metadata = @metadata.values.find do |meta|
94
+ meta[:name] == route_name && meta[:domain] == domain
95
+ end
96
+
97
+ unless found_metadata
98
+ found_metadata = @metadata.values.find do |meta|
99
+ meta[:name] == route_name && meta[:domain] == '*'
100
+ end
101
+ end
102
+
103
+ unless found_metadata
104
+ raise RouteNotFoundError.new(route_name, domain)
105
+ end
106
+
107
+ # If this is a localized route and we have a locale, use locale-aware path building
108
+ if found_metadata[:localized] && locale && domain_config
109
+ build_path_with_locale(found_metadata, params, locale, domain_config)
110
+ else
111
+ build_path_without_locale(found_metadata, params)
112
+ end
113
+ end
114
+ def match(domain:, method:, path:)
115
+
116
+ return nil if path.start_with?('//') && path != '//'
117
+ normalized_domain = domain.to_s.downcase
118
+ normalized_path = normalize_path(path)
119
+ normalized_method = method.to_sym
120
+ segments = get_path_segments(normalized_path)
121
+
122
+ match_result = nil
123
+
124
+ # 1. Try exact domain match first
125
+ domain_trie = @tries[normalized_domain]
126
+ if domain_trie
127
+ match_result = traverse_trie(domain_trie, segments)
128
+ end
129
+
130
+ # 2. Try subdomain wildcard match
131
+ unless match_result
132
+ wildcard_domain = find_wildcard_domain_match(normalized_domain)
133
+
134
+ if wildcard_domain
135
+ domain_trie = @tries[wildcard_domain]
136
+ if domain_trie
137
+ match_result = traverse_trie(domain_trie, segments)
138
+
139
+ if match_result
140
+ subdomain = extract_subdomain_from_wildcard(normalized_domain, wildcard_domain)
141
+ # Store subdomain in match result
142
+ match_result = [match_result[0], match_result[1], subdomain]
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ # 3. Try global wildcard match (existing functionality)
149
+ unless match_result
150
+ wildcard_trie = @tries['*']
151
+ if wildcard_trie
152
+ match_result = traverse_trie(wildcard_trie, segments)
153
+ end
154
+ end
155
+
156
+ return nil unless match_result
157
+
158
+ node, params, subdomain = match_result
159
+ metadata_key = node[:handlers][normalized_method]
160
+ return nil unless metadata_key
161
+
162
+ metadata = @metadata[metadata_key]
163
+
164
+ unless enforce_constraints(params, metadata[:constraints]); return nil; end
165
+
166
+ route = {
167
+ name: metadata[:name],
168
+ handler: metadata[:handler],
169
+ use: metadata[:use].dup,
170
+ params: params,
171
+ locale: metadata[:locale],
172
+ domain: normalized_domain
173
+ }
174
+
175
+ # Add subdomain to route if we matched a wildcard domain
176
+ route[:subdomain] = subdomain if subdomain
177
+
178
+ route
179
+ end
180
+
181
+ private
182
+
183
+ def find_wildcard_domain_match(domain)
184
+ # Look through all registered domains for wildcard patterns
185
+ @tries.keys.each do |registered_domain|
186
+ if registered_domain.start_with?('*.')
187
+ base_domain = registered_domain[2..] # Remove '*.'
188
+
189
+ # Check if the requested domain matches the wildcard pattern
190
+ # Examples:
191
+ # - domain: "acme.example.com" matches "*.example.com"
192
+ # - domain: "example.com" matches "*.example.com" (no subdomain case)
193
+ if domain == base_domain || domain.end_with?(".#{base_domain}")
194
+ return registered_domain
195
+ end
196
+ end
197
+ end
198
+ nil
199
+ end
200
+
201
+ def extract_subdomain_from_wildcard(full_domain, wildcard_domain)
202
+ base_domain = wildcard_domain[2..] # Remove '*.'
203
+
204
+ if full_domain == base_domain
205
+ # No subdomain (user visited example.com directly)
206
+ nil
207
+ else
208
+ # Extract subdomain by removing the base domain
209
+ # "acme.example.com" -> "acme"
210
+ # "app.staging.example.com" -> "app.staging"
211
+ full_domain.gsub(".#{base_domain}", '')
212
+ end
213
+ end
214
+ def self.find_domain_config(domain)
215
+ # Exact match first
216
+ exact_match = @domains[domain]
217
+ return exact_match if exact_match
218
+
219
+ # Then wildcard matches
220
+ @domains.each do |config_domain, config|
221
+ if config_domain.start_with?('*.')
222
+ base_domain = config_domain[2..] # Remove '*.'
223
+ if domain.end_with?(".#{base_domain}") || domain == base_domain
224
+ return config.merge(wildcard: true, base_domain: base_domain)
225
+ end
226
+ end
227
+ end
228
+
229
+ nil
230
+ end
231
+
232
+ def build_path(metadata, params)
233
+ build_path_without_locale(metadata, params)
234
+ end
235
+
236
+ def build_path_without_locale(metadata, params)
237
+ segments = metadata[:segments].dup
238
+ required_params = metadata[:params]
239
+ provided_params = params.dup
240
+ missing_params = required_params - provided_params.keys
241
+ unless missing_params.empty?
242
+ raise ArgumentError, "Missing required param(s) #{missing_params.map { |p| "'#{p}'" }.join(', ')} for route :#{metadata[:name]}"
243
+ end
244
+
245
+ path_parts = segments.map do |segment|
246
+ if segment.start_with?(':')
247
+ param_name = segment[1..].to_sym
248
+ value = provided_params.delete(param_name)
249
+ URI.encode_www_form_component(value.to_s)
250
+ elsif segment.start_with?('*')
251
+ param_name = segment[1..].to_s
252
+ param_name = 'path' if param_name.empty?
253
+ value = provided_params.delete(param_name.to_sym)
254
+ value.to_s
255
+ else
256
+ segment
257
+ end
258
+ end
259
+
260
+ path = path_parts.empty? ? '/' : '/' + path_parts.join('/')
261
+ unless provided_params.empty?
262
+ query_string = URI.encode_www_form(provided_params)
263
+ path += "?#{query_string}"
264
+ end
265
+ path
266
+ end
267
+
268
+ def build_path_with_locale(metadata, params, locale, domain_config)
269
+ # Validate locale is available for this domain
270
+ unless domain_config[:locales].include?(locale)
271
+ raise LocaleError, "Locale :#{locale} not available for domain '#{metadata[:domain]}'. Available locales: #{domain_config[:locales].inspect}"
272
+ end
273
+
274
+ # For localized routes, we need to find the actual locale-specific route metadata
275
+ localized_name = "#{metadata[:name]}_#{locale}".to_sym
276
+ # Find the locale-specific route
277
+ localized_metadata = @metadata.values.find do |meta|
278
+ meta[:name] == localized_name && meta[:domain] == metadata[:domain]
279
+ end
280
+
281
+ if localized_metadata
282
+ # Use the locale-specific route's segments
283
+ build_path_without_locale(localized_metadata, params)
284
+ else
285
+ # Fallback: build path manually with locale prefix
286
+ base_path = build_path_without_locale(metadata, params)
287
+ if base_path == '/'
288
+ "/#{locale}"
289
+ else
290
+ "/#{locale}#{base_path}"
291
+ end
292
+ end
293
+ end
294
+
295
+ def self.not_found(request, response = nil)
296
+ handler = @@default_not_found_handler
297
+ handler ? handler.call(request, {}) : [404, {'content-type' => 'text/plain'}, ['Not Found']]
298
+ end
299
+
300
+ def self.error(request, exception)
301
+ handler = @@default_error_handler
302
+ handler ? handler.call(request, exception) : [500, {'content-type' => 'text/plain'}, ['Internal Server Error']]
303
+ end
304
+ def self.error_response(request, exception)
305
+ handler = @@default_error_handler
306
+ return handler.call(request, exception) if handler
307
+ [500, {'content-type' => 'text/plain'}, ['Internal Server Error']]
308
+ end
309
+ private
310
+
311
+ def merge_use(parent_use, child_use)
312
+ parent = parent_use.is_a?(Array) ? parent_use : []
313
+ child = child_use.is_a?(Array) ? child_use : []
314
+ (parent + child).uniq
315
+ end
316
+
317
+ def reset!
318
+ @tries = {}
319
+ @metadata = {}
320
+ @named_routes = {}
321
+ @route_name_registry = Set.new
322
+ @segment_cache = {}
323
+ @cache_max_size = 1000
324
+ @@default_domain = nil
325
+ end
326
+ def compile!(config)
327
+ @domain_configs = {}
328
+
329
+ config.each do |domain_key, domain_config|
330
+ domain = domain_key.to_s.downcase
331
+
332
+ # Store domain configuration (locales, etc.)
333
+ if domain_config.is_a?(Hash) && (domain_config[:locales] || domain_config[:default_locale])
334
+ locales = Array(domain_config[:locales])
335
+ default_locale = domain_config[:default_locale]
336
+
337
+ # Validate that default_locale is in locales
338
+ if default_locale && !locales.include?(default_locale)
339
+ raise LocaleError, "Default locale '#{default_locale}' not found in locales #{locales.inspect} for domain '#{domain}'"
340
+ end
341
+
342
+ # Validate that all localized routes use valid locales
343
+ validate_localized_routes(domain_config, locales, domain)
344
+
345
+ store_domain_config(domain, {
346
+ locales: locales,
347
+ default_locale: default_locale || locales.first,
348
+ root_locale_redirect: domain_config.fetch(:root_locale_redirect, true)
349
+ })
350
+ end
351
+
352
+ # Handle wildcard domains - store them as-is in tries
353
+ # This preserves the "*.example.com" pattern for matching later
354
+ @tries[domain] = new_trie_node
355
+
356
+ # Compile routes for this domain (wildcard or regular)
357
+ compile_scope(config: domain_config, domain: domain, path_segments: [], inherited_use: [])
358
+ end
359
+ end
360
+
361
+ def validate_localized_routes(domain_config, valid_locales, domain)
362
+ check_config_for_locales(domain_config, valid_locales, domain, '')
363
+ end
364
+
365
+ def check_config_for_locales(config, valid_locales, domain, path = '')
366
+ return unless config.is_a?(Hash)
367
+
368
+ config.each do |key, value|
369
+ if value.is_a?(Hash) && value[:localized]
370
+ invalid_locales = value[:localized].keys - valid_locales
371
+ if invalid_locales.any?
372
+ raise LocaleError, "Handler at #{path}/#{key} uses invalid locales #{invalid_locales.inspect} but domain only supports #{valid_locales.inspect}"
373
+ end
374
+ elsif value.is_a?(Hash)
375
+ # Recursively check nested routes
376
+ check_config_for_locales(value, valid_locales, domain, "#{path}/#{key}")
377
+ end
378
+ end
379
+ end
380
+
381
+ def compile_scope(config:, domain:, path_segments:, inherited_use:)
382
+ return unless config.is_a?(Hash)
383
+
384
+ if config.key?(:use)
385
+ scope_use_value = config[:use]
386
+ if scope_use_value.nil?
387
+ current_use = []
388
+ else
389
+ resolved_scope_use = Array(scope_use_value).flat_map do |item|
390
+ if item.is_a?(Symbol)
391
+ Aris.resolve_plugin(item) # Returns array of classes
392
+ else
393
+ item
394
+ end
395
+ end
396
+ current_use = merge_use(inherited_use, resolved_scope_use)
397
+ end
398
+ else
399
+ current_use = inherited_use.is_a?(Array) ? inherited_use : []
400
+ end
401
+ config.each do |key, value|
402
+ next if key == :use
403
+ if HTTP_METHODS.include?(key)
404
+ register_route(domain: domain, method: key, path_segments: path_segments, route_config: value, inherited_use: current_use)
405
+ elsif key.is_a?(String) || key.is_a?(Symbol)
406
+ new_segments = parse_path_key(key.to_s)
407
+ compile_scope(config: value, domain: domain, path_segments: path_segments + new_segments, inherited_use: current_use)
408
+ end
409
+ end
410
+ end
411
+
412
+ def register_route(domain:, method:, path_segments:, route_config:, inherited_use:)
413
+ name = route_config[:as]
414
+ handler = route_config[:to]
415
+ constraints = route_config[:constraints] || {}
416
+ route_use_unresolved = route_config[:use]
417
+ localized_paths = route_config[:localized]
418
+
419
+ resolved_inherited = Array(inherited_use).flat_map do |item|
420
+ if item.is_a?(Symbol)
421
+ Aris.resolve_plugin(item)
422
+ else
423
+ item
424
+ end
425
+ end
426
+
427
+ if route_use_unresolved
428
+ resolved_route_use = Array(route_use_unresolved).flat_map do |item|
429
+ if item.is_a?(Symbol)
430
+ Aris.resolve_plugin(item)
431
+ else
432
+ item
433
+ end
434
+ end
435
+ route_use = resolved_route_use
436
+ else
437
+ route_use = nil
438
+ end
439
+
440
+ if route_use
441
+ final_use = merge_use(resolved_inherited, route_use)
442
+ else
443
+ final_use = resolved_inherited
444
+ end
445
+
446
+ # Handle localized routes
447
+ if localized_paths && !localized_paths.empty?
448
+ domain_config = self.domain_config(domain)
449
+ if domain_config && domain_config[:locales]
450
+ # Warn about missing locales (routes that don't cover all domain locales)
451
+ missing_locales = domain_config[:locales] - localized_paths.keys
452
+ if missing_locales.any?
453
+ warn "Warning: Route '#{build_pattern(path_segments)}' missing locales: #{missing_locales.inspect}"
454
+ end
455
+
456
+ # Register the base route for URL generation (without locale prefix)
457
+ if name
458
+ @route_name_registry.add(name)
459
+ base_metadata_key = build_metadata_key(domain, method, build_pattern(path_segments))
460
+ @metadata[base_metadata_key] = {
461
+ domain: domain,
462
+ name: name,
463
+ handler: handler,
464
+ use: final_use,
465
+ pattern: build_pattern(path_segments),
466
+ params: extract_param_names(path_segments),
467
+ segments: path_segments.dup,
468
+ constraints: constraints,
469
+ localized: true
470
+ }
471
+ @named_routes[name] = base_metadata_key
472
+ end
473
+
474
+ # Register locale-specific routes (with locale prefix and localized segment)
475
+ localized_paths.each do |locale, localized_path|
476
+ if domain_config[:locales].include?(locale)
477
+ # Parse the localized path (e.g., "about" or "products/:id")
478
+ localized_segments = parse_path_key(localized_path.to_s)
479
+
480
+ # Combine: locale + localized_segments
481
+ full_path_segments = [locale.to_s] + localized_segments
482
+
483
+ register_localized_route(
484
+ domain: domain,
485
+ method: method,
486
+ path_segments: full_path_segments,
487
+ route_config: route_config,
488
+ inherited_use: final_use,
489
+ name: name,
490
+ handler: handler,
491
+ constraints: constraints,
492
+ locale: locale
493
+ )
494
+ else
495
+ raise LocaleError, "Locale '#{locale}' not found in domain '#{domain}' locales: #{domain_config[:locales].inspect}"
496
+ end
497
+ end
498
+ end
499
+ return
500
+ end
501
+
502
+ # Original registration for non-localized routes
503
+ if name
504
+ if @route_name_registry.include?(name)
505
+ existing = @named_routes[name]
506
+ existing_meta = @metadata[existing]
507
+ raise DuplicateRouteNameError.new(
508
+ name: name,
509
+ existing_domain: existing_meta[:domain],
510
+ existing_pattern: existing_meta[:pattern],
511
+ new_domain: domain,
512
+ new_pattern: build_pattern(path_segments)
513
+ )
514
+ end
515
+ @route_name_registry.add(name)
516
+ end
517
+
518
+ pattern = build_pattern(path_segments)
519
+ param_names = extract_param_names(path_segments)
520
+ metadata_key = build_metadata_key(domain, method, pattern)
521
+ @metadata[metadata_key] = {
522
+ domain: domain,
523
+ name: name,
524
+ handler: handler,
525
+ use: final_use,
526
+ pattern: pattern,
527
+ params: param_names,
528
+ segments: path_segments.dup,
529
+ constraints: constraints
530
+ }
531
+ @named_routes[name] = metadata_key if name
532
+ insert_into_trie(domain, path_segments, method, metadata_key)
533
+ end
534
+
535
+ def register_localized_route(domain:, method:, path_segments:, route_config:, inherited_use:, name:, handler:, constraints:, locale:)
536
+ # Create locale-specific name
537
+ localized_name = name ? "#{name}_#{locale}".to_sym : nil
538
+
539
+ if localized_name && @route_name_registry.include?(localized_name)
540
+ existing = @named_routes[localized_name]
541
+ existing_meta = @metadata[existing]
542
+ raise DuplicateRouteNameError.new(
543
+ name: localized_name,
544
+ existing_domain: existing_meta[:domain],
545
+ existing_pattern: existing_meta[:pattern],
546
+ new_domain: domain,
547
+ new_pattern: build_pattern(path_segments)
548
+ )
549
+ end
550
+
551
+ # DO NOT add locale prefix here - the path_segments already include it from the route config
552
+ pattern = build_pattern(path_segments)
553
+ param_names = extract_param_names(path_segments)
554
+ metadata_key = build_metadata_key(domain, method, pattern)
555
+
556
+ @metadata[metadata_key] = {
557
+ domain: domain,
558
+ name: localized_name,
559
+ handler: handler,
560
+ use: inherited_use,
561
+ pattern: pattern,
562
+ params: param_names,
563
+ segments: path_segments.dup,
564
+ constraints: constraints,
565
+ locale: locale
566
+ }
567
+
568
+ @route_name_registry.add(localized_name) if localized_name
569
+ @named_routes[localized_name] = metadata_key if localized_name
570
+ insert_into_trie(domain, path_segments, method, metadata_key)
571
+ end
572
+ def enforce_constraints(params, constraints)
573
+ constraints.each do |param_name, regex|
574
+ value = params[param_name]
575
+ if value && !value.match?(regex)
576
+ return false
577
+ end
578
+ end
579
+ true
580
+ end
581
+ def extract_use(config)
582
+ return nil unless config.is_a?(Hash)
583
+ return nil unless config.key?(:use)
584
+ use_value = config[:use]
585
+ return [] if use_value.nil?
586
+ Array(use_value)
587
+ end
588
+
589
+ def new_trie_node
590
+ { literal_children: {}, param_child: nil, wildcard_child: nil, handlers: {} }
591
+ end
592
+
593
+ def insert_into_trie(domain, path_segments, method, metadata_key)
594
+ node = @tries[domain]
595
+ path_segments.each { |segment| node = insert_segment(node, segment) }
596
+ node[:handlers][method] = metadata_key
597
+ end
598
+
599
+ def insert_segment(node, segment)
600
+ if segment.start_with?('*')
601
+ wildcard_name = segment[1..].to_s
602
+ wildcard_name = 'path' if wildcard_name.empty?
603
+ unless node[:wildcard_child]
604
+ node[:wildcard_child] = { name: wildcard_name.to_sym, node: new_trie_node }
605
+ end
606
+ node[:wildcard_child][:node]
607
+ elsif segment.start_with?(':')
608
+ param_name = segment[1..].to_sym
609
+ unless node[:param_child]
610
+ node[:param_child] = { name: param_name, node: new_trie_node }
611
+ end
612
+ node[:param_child][:node]
613
+ else
614
+ node[:literal_children][segment] ||= new_trie_node
615
+ end
616
+ end
617
+
618
+ def traverse_trie(node, segments, params = {})
619
+ return nil unless node
620
+ return [node, params] if segments.empty?
621
+ current_segment = segments.first
622
+ remaining_segments = segments[1..]
623
+ if node[:literal_children][current_segment]
624
+ result = traverse_trie(node[:literal_children][current_segment], remaining_segments, params)
625
+ return result if result
626
+ end
627
+ if node[:param_child]
628
+ param_name = node[:param_child][:name]
629
+ params[param_name] = current_segment
630
+ result = traverse_trie(node[:param_child][:node], remaining_segments, params)
631
+ if result
632
+ return result
633
+ else
634
+ params.delete(param_name)
635
+ end
636
+ end
637
+ if node[:wildcard_child]
638
+ wildcard_name = node[:wildcard_child][:name]
639
+ (0..segments.size).each do |i|
640
+ captured_segments = segments[0..i]
641
+ remaining_after_wildcard = segments[(i + 1)..] || []
642
+ captured_path = captured_segments.join('/')
643
+ params[wildcard_name] = captured_path
644
+ result = traverse_trie(node[:wildcard_child][:node], remaining_after_wildcard, params)
645
+ if result
646
+ return result
647
+ else
648
+ params.delete(wildcard_name)
649
+ end
650
+ end
651
+ end
652
+ nil
653
+ end
654
+
655
+ def build_path(metadata, params)
656
+ segments = metadata[:segments].dup
657
+ required_params = metadata[:params]
658
+ provided_params = params.dup
659
+ missing_params = required_params - provided_params.keys
660
+ unless missing_params.empty?
661
+ raise ArgumentError, "Missing required param(s) #{missing_params.map { |p| "'#{p}'" }.join(', ')} for route :#{metadata[:name]}"
662
+ end
663
+ path_parts = segments.map do |segment|
664
+ if segment.start_with?(':')
665
+ param_name = segment[1..].to_sym
666
+ value = provided_params.delete(param_name)
667
+ URI.encode_www_form_component(value.to_s)
668
+ elsif segment.start_with?('*')
669
+ param_name = segment[1..].to_s
670
+ param_name = 'path' if param_name.empty?
671
+ value = provided_params.delete(param_name.to_sym)
672
+ value.to_s
673
+ else
674
+ segment
675
+ end
676
+ end
677
+ path = path_parts.empty? ? '/' : '/' + path_parts.join('/')
678
+ unless provided_params.empty?
679
+ query_string = URI.encode_www_form(provided_params)
680
+ path += "?#{query_string}"
681
+ end
682
+ path
683
+ end
684
+
685
+ def parse_path_key(key)
686
+ key.split('/').reject(&:empty?)
687
+ end
688
+
689
+ def build_pattern(segments)
690
+ segments.empty? ? '/' : '/' + segments.join('/')
691
+ end
692
+
693
+ def extract_param_names(segments)
694
+ segments.select { |s| s.start_with?(':') || s.start_with?('*') }.map { |s| s[1..].to_sym }
695
+ end
696
+
697
+ def build_metadata_key(domain, method, pattern)
698
+ "#{domain}:#{method.to_s.upcase}:#{pattern}"
699
+ end
700
+
701
+ def get_path_segments(normalized_path)
702
+ if cached = @segment_cache[normalized_path]
703
+ return cached
704
+ end
705
+
706
+ if normalized_path == '/'
707
+ segments = []
708
+ else
709
+ segments = normalized_path.split('/').reject(&:empty?)
710
+
711
+ # In strict mode, preserve trailing slash by appending to last segment
712
+ if Aris::Config.trailing_slash == :strict &&
713
+ normalized_path.end_with?('/') &&
714
+ normalized_path != '/' &&
715
+ segments.any?
716
+ segments[-1] = segments[-1] + '/'
717
+ end
718
+ end
719
+
720
+ if @segment_cache.size >= @cache_max_size
721
+ @segment_cache.clear
722
+ end
723
+ @segment_cache[normalized_path] = segments
724
+ segments
725
+ end
726
+ def normalize_path(path)
727
+ return '/' if path.empty?
728
+ return path if path == '/' || (path == '/users' && !path.include?('//'))
729
+
730
+ if path.include?('//')
731
+ path = path.gsub(%r{/+}, '/')
732
+ end
733
+
734
+ # Don't strip trailing slash in strict mode
735
+ normalized = if Aris::Config.trailing_slash == :strict
736
+ path # Keep trailing slash in strict mode
737
+ else
738
+ path.length > 1 && path.end_with?('/') ? path.chomp('/') : path
739
+ end
740
+
741
+ if normalized.include?('%')
742
+ begin
743
+ normalized = URI.decode_www_form_component(normalized)
744
+ rescue
745
+ end
746
+ end
747
+
748
+ normalized.downcase
749
+ end
750
+ class RouteNotFoundError < StandardError
751
+ def initialize(name, domain)
752
+ # FIX: Concatenate arguments into a single string before calling super
753
+ super("Named route :#{name} not found on domain '#{domain}' or '*' fallback.")
754
+ end
755
+ end
756
+ class DuplicateRouteNameError < StandardError
757
+ end
758
+ class LocaleError < StandardError
759
+ def initialize(message)
760
+ super(message)
761
+ end
762
+ end
763
+ end
764
+
765
+ module CurrentDomain
766
+ extend self
767
+ THREAD_KEY = :aris_current_domain
768
+ def current
769
+ Thread.current[THREAD_KEY] ||
770
+ Aris::Router.default_domain ||
771
+ raise("No domain context available. Set Thread.current[:aris_current_domain] or Aris::Router.default_domain")
772
+ end
773
+
774
+ def with_domain(domain, &block)
775
+ original = Thread.current[THREAD_KEY]
776
+ Thread.current[THREAD_KEY] = domain
777
+ yield
778
+ ensure
779
+ Thread.current[:aris_current_domain] = original
780
+ end
781
+ end
782
+
783
+ module PathHelper
784
+ extend self
785
+
786
+ def extract_domain_and_route(args)
787
+ case args.size
788
+ when 1
789
+ [CurrentDomain.current, args[0]]
790
+ when 2
791
+ [args[0].to_s.downcase, args[1]]
792
+ else
793
+ raise ArgumentError, "Expected 1 or 2 arguments, got #{args.size}"
794
+ end
795
+ end
796
+
797
+ def call(*args, **params)
798
+ domain, route_name = extract_domain_and_route(args)
799
+ clean_domain = domain.to_s.sub(%r{^https?://}, '').downcase
800
+ Aris::Router.build_named_path(route_name, domain: clean_domain, **params)
801
+ end
802
+ end
803
+
804
+ module URLHelper
805
+ extend self
806
+ def call(*args, protocol: 'https', **params)
807
+ domain, route_name = PathHelper.extract_domain_and_route(args)
808
+ path = PathHelper.call(*args, **params)
809
+ clean_assembly_domain = domain.sub(%r{^https?://}, '')
810
+ "#{protocol}://#{clean_assembly_domain}#{path}"
811
+ end
812
+ end
813
+
814
+ private
815
+
816
+ def extract_domain_and_route(args)
817
+ case args.size
818
+ when 1
819
+ [CurrentDomain.current, args[0]]
820
+ when 2
821
+ [args[0].to_s.downcase, args[1]]
822
+ else
823
+ raise ArgumentError, "Expected 1 or 2 arguments, got #{args.size}"
824
+ end
825
+ end
826
+
827
+ def current_domain
828
+ CurrentDomain.current
829
+ end
830
+
831
+ def handle_trailing_slash(path)
832
+ mode = Config.trailing_slash
833
+
834
+ return [nil, path] if mode == :strict
835
+ return [nil, path] unless path.end_with?('/')
836
+ return [nil, path] if path == '/' # Root path exception
837
+
838
+ normalized = path.chomp('/')
839
+
840
+ case mode
841
+ when :redirect
842
+ [[Config.trailing_slash_status, {'Location' => normalized}, []], nil]
843
+ when :ignore
844
+ [nil, normalized]
845
+ else
846
+ [nil, path]
847
+ end
848
+ end
849
+
850
+ def path(*args, **kwargs)
851
+ domain_to_match, route_name = PathHelper.extract_domain_and_route(args)
852
+ clean_domain = domain_to_match.sub(%r{^https?://}, '').downcase
853
+ Router.build_named_path(route_name, domain: clean_domain, **kwargs)
854
+ end
855
+
856
+ def url(*args, protocol: 'https', **kwargs)
857
+ domain_to_assemble = if args.size == 2
858
+ args[0].to_s
859
+ else
860
+ current_domain
861
+ end
862
+ path = self.path(*args, **kwargs)
863
+ clean_assembly_domain = domain_to_assemble.sub(%r{^https?://}, '')
864
+ "#{protocol}://#{clean_assembly_domain}#{path}"
865
+ end
866
+
867
+ def with_domain(domain, &block)
868
+ CurrentDomain.with_domain(domain, &block)
869
+ end
870
+ def default(config); Router.set_defaults(config); end
871
+
872
+ def redirect(target, status: 302, **params)
873
+ url = if target.is_a?(Symbol)
874
+ self.url(target, **params)
875
+ else
876
+ target.to_s
877
+ end
878
+ [status, {'content-type' => 'text/plain', 'Location' => url}, []]
879
+ end
880
+
881
+ def routes(config = nil, from_discovery: false, **kwargs)
882
+ # Ruby 3 compatibility: if config is nil and kwargs present, treat kwargs as config
883
+ config = kwargs if config.nil? && !kwargs.empty?
884
+
885
+ if config.nil? || (!config.is_a?(Hash) && kwargs.empty?)
886
+ raise ArgumentError, "Aris.routes requires a configuration hash."
887
+ end
888
+
889
+ unless from_discovery
890
+ Aris::Utils::Sitemap.reset! if defined?(Aris::Utils::Sitemap)
891
+ Aris::Utils::Redirects.reset! if defined?(Aris::Utils::Redirects)
892
+ extract_utils_metadata(config) if config.is_a?(Hash)
893
+ end
894
+
895
+ raise ArgumentError, "Aris.routes requires a configuration hash." unless config.is_a?(Hash)
896
+ Router.define(config)
897
+ end
898
+ def extract_utils_metadata(routes_hash, domain: nil, path_parts: [])
899
+ return unless routes_hash.is_a?(Hash)
900
+
901
+ # Store domain config if present
902
+ if domain && (routes_hash[:locales] || routes_hash[:default_locale])
903
+ Aris::Router.store_domain_config(domain, {
904
+ locales: Array(routes_hash[:locales]),
905
+ default_locale: routes_hash[:default_locale],
906
+ root_locale_redirect: routes_hash.fetch(:root_locale_redirect, true)
907
+ })
908
+ end
909
+
910
+ routes_hash.each do |key, value|
911
+ next unless value.is_a?(Hash)
912
+
913
+ if key.is_a?(Symbol) && [:get, :post, :put, :patch, :delete, :options].include?(key)
914
+ path = path_parts.empty? ? '/' : path_parts.join('')
915
+
916
+ if defined?(Aris::Utils::Sitemap) && value[:sitemap]
917
+ Aris::Utils::Sitemap.register(domain: domain, path: path, method: key, metadata: value[:sitemap])
918
+ end
919
+
920
+ if defined?(Aris::Utils::Redirects) && value[:redirects_from]
921
+ Aris::Utils::Redirects.register(from_paths: value[:redirects_from], to_path: path, status: value[:redirect_status] || 301)
922
+ end
923
+ else
924
+ new_domain = domain || key.to_s
925
+ new_path = domain ? path_parts + [key.to_s] : path_parts
926
+ extract_utils_metadata(value, domain: new_domain, path_parts: new_path)
927
+ end
928
+ end
929
+ end
930
+ module_function :path, :url, :with_domain, :current_domain, :routes, :default, :redirect, :extract_utils_metadata, :handle_trailing_slash
931
+ end