aris 1.4.2 → 1.5.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 +4 -4
  2. data/CHANGELOG.md +251 -0
  3. data/README.md +18 -0
  4. data/docs/ADAPTERS.md +478 -0
  5. data/docs/ARCHITECTURE.md +222 -0
  6. data/docs/CONTENT.md +967 -0
  7. data/docs/PERFORMANCE.md +492 -0
  8. data/docs/PLUGIN_DEVELOPMENT.md +688 -0
  9. data/docs/USAGE.md +4998 -0
  10. data/docs/plugins/API_KEY_AUTH.md +232 -0
  11. data/docs/plugins/BASIC_AUTH.md +582 -0
  12. data/docs/plugins/BEARER_AUTH.md +394 -0
  13. data/docs/plugins/CACHE.md +369 -0
  14. data/docs/plugins/COMPRESSION.md +216 -0
  15. data/docs/plugins/COOKIES.md +30 -0
  16. data/docs/plugins/CORS.md +283 -0
  17. data/docs/plugins/CSRF.md +751 -0
  18. data/docs/plugins/ETAG.md +308 -0
  19. data/docs/plugins/FORM_PARSER.md +193 -0
  20. data/docs/plugins/HEALTH_CHECK.md +469 -0
  21. data/docs/plugins/JSON.md +291 -0
  22. data/docs/plugins/MULTIPART.md +427 -0
  23. data/docs/plugins/RATE_LIMITER.md +368 -0
  24. data/docs/plugins/REQUEST_ID.md +369 -0
  25. data/docs/plugins/REQUEST_LOGGER.md +151 -0
  26. data/docs/plugins/SECURITY.md +193 -0
  27. data/docs/plugins/SESSION.md +98 -0
  28. data/lib/aris/adapters/rack/adapter.rb +17 -2
  29. data/lib/aris/adapters/rack/request.rb +29 -11
  30. data/lib/aris/plugins/basic_auth.rb +3 -1
  31. data/lib/aris/plugins/cookies.rb +4 -32
  32. data/lib/aris/plugins/cors.rb +8 -1
  33. data/lib/aris/plugins/csrf.rb +63 -22
  34. data/lib/aris/plugins/flash.rb +3 -1
  35. data/lib/aris/plugins/form_parser.rb +52 -31
  36. data/lib/aris/plugins/multipart.rb +22 -2
  37. data/lib/aris/plugins/request_logger.rb +8 -1
  38. data/lib/aris/plugins/security_headers.rb +8 -1
  39. data/lib/aris/plugins/session.rb +150 -99
  40. data/lib/aris/response_helpers.rb +41 -0
  41. data/lib/aris/version.rb +2 -2
  42. metadata +31 -3
data/docs/USAGE.md ADDED
@@ -0,0 +1,4998 @@
1
+ # Aris Router - Complete Usage Guide
2
+
3
+ This guide covers everything you need to know about using Aris, from basic routing to advanced patterns. We will start with fundamental concepts and build up to more sophisticated use cases, explaining the reasoning behind each design decision along the way.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [Core Philosophy](#core-philosophy)
10
+ 2. [Defining Routes](#defining-routes)
11
+ 3. [Route Matching](#route-matching)
12
+ 4. [Path and URL Generation](#path-and-url-generation)
13
+ 5. [HTTP Methods](#http-methods)
14
+ 6. [Parameters and Wildcards](#parameters-and-wildcards)
15
+ 7. [AutoDiscovery Routes](#auto-discovery)
16
+ 8. [Constraints](#constraints)
17
+ 9. [Multi-Domain Routing](#multi-domain-routing)
18
+ 10. [The Plugin System](#the-plugin-system)
19
+ 11. [Error Handling](#error-handling)
20
+ 12. [Rack Integration](#rack-integration)
21
+ 13. [Standalone Usage](#standalone-usage)
22
+ 14. [Advanced Patterns](#advanced-patterns)
23
+ 15. [Complete API Reference](#complete-api-reference)
24
+
25
+ ---
26
+
27
+ ## Core Philosophy
28
+
29
+ Before we dive into the specifics, it helps to understand what makes Aris different from other routing libraries you may have used.
30
+
31
+ ### Routes as Data
32
+
33
+ Most Ruby routers use domain-specific languages that execute code during route definition. When you write something like `get '/users/:id', to: 'users#show'` in Rails, you are actually calling methods that build internal data structures at runtime. This feels natural and reads well, but it introduces a layer of abstraction between your intent and the actual routing logic.
34
+
35
+ Aris takes a different approach. Routes are defined as plain Ruby hashes—literal data structures that describe the shape of your application's URL space. This choice has several implications that ripple through the entire design.
36
+
37
+ When routes are data, they become serializable. You can load them from YAML files, generate them from database records, or build them programmatically using any Ruby code you want. They can be inspected, transformed, and tested like any other data structure. There is no magic happening behind the scenes, no implicit state being managed, no hidden method calls evaluating blocks.
38
+
39
+ This data-first design also enables aggressive optimization. Since Aris knows the complete routing table upfront, it can compile it into an optimized Trie structure at boot time. Every possible path through your application is analyzed once, and the resulting lookup structure is frozen for the lifetime of the process. This is why Aris can route requests in under a microsecond—the hard work is done before the first request arrives.
40
+
41
+ ### Framework Agnosticism
42
+
43
+ The second core principle is complete independence from any web framework. Aris exposes a simple functional interface: you give it a domain, method, and path, and it returns routing metadata. That is it. No assumptions about Rack, no Rails conventions, no Sinatra DSL patterns.
44
+
45
+ This agnosticism is not just philosophical—it opens up use cases that are awkward or impossible with framework-coupled routers. You can use Aris to route commands in a CLI application, dispatch events in a background job system, or build a custom HTTP server that bypasses Ruby's standard library entirely. The routing logic is yours to use however you need.
46
+
47
+ ### Explicit Over Implicit
48
+
49
+ The third principle is explicitness. Aris does not try to be clever or infer your intentions. Every route requires an explicit domain. Every handler must be explicitly specified. Every plugin must be explicitly listed.
50
+
51
+ This can feel verbose at first, especially if you are coming from frameworks that do a lot of work behind the scenes. But explicitness has a payoff: when something goes wrong, you know exactly where to look. There are no hidden middleware chains, no automatic route generation, no framework magic that might or might not apply in your specific situation.
52
+
53
+ With these principles in mind, let us start building.
54
+
55
+ ---
56
+
57
+ ## Defining Routes
58
+
59
+ Route definition in Aris happens through a single method: `Aris.routes`. This method takes a configuration hash and performs a complete reset and recompilation of the routing table. Let us start with the simplest possible route definition and build up from there.
60
+
61
+ ### Your First Route
62
+
63
+ ```ruby
64
+ Aris.routes({
65
+ "example.com": {
66
+ "/": {
67
+ get: { to: HomeHandler }
68
+ }
69
+ }
70
+ })
71
+ ```
72
+
73
+ This defines a single route on `example.com` that responds to GET requests at the root path. The handler is `HomeHandler`, which should be a callable object (we will cover what that means shortly).
74
+
75
+ Notice the structure here. The outermost hash keys are domains. Each domain contains a hash of path segments. Each path segment contains HTTP method definitions. Each method definition specifies a handler and optional metadata.
76
+
77
+ This nesting might feel unusual at first, but it has a purpose. The structure of the hash mirrors the conceptual structure of your routing tree. Routes are organized by domain, then by path, then by method. This organization makes it easy to see which routes belong to which parts of your application.
78
+
79
+ ### Adding Named Routes
80
+
81
+ Most applications need to generate URLs from route definitions. This is where named routes come in.
82
+
83
+ ```ruby
84
+ Aris.routes({
85
+ "example.com": {
86
+ "/": {
87
+ get: { to: HomeHandler, as: :home }
88
+ },
89
+ "/about": {
90
+ get: { to: AboutHandler, as: :about }
91
+ }
92
+ }
93
+ })
94
+ ```
95
+
96
+ The `as:` option gives the route a name that you can use later for path generation. Route names must be unique across your entire routing table—if you try to define two routes with the same name, Aris will raise an error during compilation.
97
+
98
+ This uniqueness constraint is intentional. Named routes create a stable API for URL generation throughout your application. If the same name could refer to different routes depending on context, you would lose that stability and introduce potential bugs.
99
+
100
+ ### Nested Path Segments
101
+
102
+ Real applications need more than flat route tables. Paths are hierarchical, and your route definitions should reflect that hierarchy.
103
+
104
+ ```ruby
105
+ Aris.routes({
106
+ "example.com": {
107
+ "/users": {
108
+ get: { to: UsersIndexHandler, as: :users },
109
+
110
+ "/:id": {
111
+ get: { to: UserShowHandler, as: :user },
112
+
113
+ "/posts": {
114
+ get: { to: UserPostsHandler, as: :user_posts }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ })
120
+ ```
121
+
122
+ This defines three routes:
123
+ - `GET /users` - handled by `UsersIndexHandler`
124
+ - `GET /users/:id` - handled by `UserShowHandler`
125
+ - `GET /users/:id/posts` - handled by `UserPostsHandler`
126
+
127
+ The nesting structure makes it clear that these routes are related. They all live under the `/users` namespace, and you can see at a glance how they connect to each other. If you later need to add authentication to all user routes, you know exactly where to add the plugin (we will cover plugins soon).
128
+
129
+ Notice that path segments can be literal strings like `/posts` or parameterized patterns like `/:id`. When a segment starts with a colon, Aris treats it as a parameter that will capture whatever value appears in that position in the incoming request.
130
+
131
+ ### Multiple Domains
132
+
133
+ Modern applications often serve multiple domains. Perhaps you have a public site, an admin dashboard, and an API, each on its own subdomain or domain entirely.
134
+
135
+ ```ruby
136
+ Aris.routes({
137
+ "example.com": {
138
+ "/": { get: { to: PublicHomeHandler } }
139
+ },
140
+
141
+ "admin.example.com": {
142
+ "/": { get: { to: AdminDashboardHandler } }
143
+ },
144
+
145
+ "api.example.com": {
146
+ "/v1": {
147
+ "/users": { get: { to: ApiUsersHandler } }
148
+ }
149
+ }
150
+ })
151
+ ```
152
+
153
+ Each domain gets its own routing tree. These trees are completely independent—you can have a `/users` route on both `example.com` and `api.example.com`, and they will not conflict. The domain is always part of the routing decision, so Aris knows exactly which tree to search.
154
+
155
+ This domain-level isolation is powerful for multi-tenant applications. Each tenant can have its own domain with its own routing rules, all managed in a single configuration.
156
+
157
+ ### The Wildcard Domain
158
+
159
+ Sometimes you need routes that work on any domain. Health checks, status endpoints, and other infrastructure concerns often fall into this category.
160
+
161
+ ```ruby
162
+ Aris.routes({
163
+ "example.com": {
164
+ "/": { get: { to: HomeHandler } }
165
+ },
166
+
167
+ "*": {
168
+ "/health": { get: { to: HealthCheckHandler } },
169
+ "/metrics": { get: { to: MetricsHandler } }
170
+ }
171
+ })
172
+ ```
173
+
174
+ The special domain `"*"` acts as a fallback. When a request comes in, Aris first checks if there is a specific domain match. If not, it falls back to the wildcard domain routes.
175
+
176
+ This fallback behavior is important to understand. If you define a `/health` route on both `example.com` and the wildcard domain, a request to `example.com/health` will match the specific domain route, not the wildcard. Specific domains always win.
177
+
178
+ ---
179
+
180
+ ## Route Matching
181
+
182
+ Once routes are defined, you need to match incoming requests against them. This is where the routing engine shows its speed.
183
+
184
+ ### Basic Matching
185
+
186
+ The core matching method is `Aris::Router.match`. It takes three required parameters and returns either a hash of routing metadata or nil if no route matches.
187
+
188
+ ```ruby
189
+ result = Aris::Router.match(
190
+ domain: "example.com",
191
+ method: :get,
192
+ path: "/users/123"
193
+ )
194
+
195
+ if result
196
+ # Route was found
197
+ handler = result[:handler] # The handler to call
198
+ params = result[:params] # Extracted parameters: { id: "123" }
199
+ name = result[:name] # The route name (if it has one)
200
+ plugins = result[:use] # Plugins to execute (more on this later)
201
+ else
202
+ # No route matched
203
+ end
204
+ ```
205
+
206
+ The returned hash contains everything you need to handle the request. The handler is exactly what you specified in your route definition. The params hash contains any values extracted from parameterized path segments. The name is the route's `as:` value if you provided one. The plugins array contains any middleware that should execute before the handler.
207
+
208
+ ### Parameter Extraction
209
+
210
+ When a route contains parameterized segments (those starting with `:`), Aris extracts the corresponding values from the request path and returns them in the params hash.
211
+
212
+ ```ruby
213
+ Aris.routes({
214
+ "example.com": {
215
+ "/posts/:year/:month/:slug": {
216
+ get: { to: PostHandler }
217
+ }
218
+ }
219
+ })
220
+
221
+ result = Aris::Router.match(
222
+ domain: "example.com",
223
+ method: :get,
224
+ path: "/posts/2024/03/hello-world"
225
+ )
226
+
227
+ result[:params]
228
+ # => { year: "2024", month: "03", slug: "hello-world" }
229
+ ```
230
+
231
+ All parameter values are strings. Aris does not attempt to convert them to integers, dates, or any other type. This is intentional—type coercion belongs in your application layer where you have context about what the values mean and how they should be validated.
232
+
233
+ ### Path Normalization
234
+
235
+ Before matching, Aris normalizes the incoming path to ensure consistent behavior. Understanding this normalization is important for writing reliable routes.
236
+
237
+ First, trailing slashes are stripped from all paths except the root. This means `/users` and `/users/` are treated identically. You do not need to define separate routes for both variants.
238
+
239
+ ```ruby
240
+ # These all match the same route
241
+ Aris::Router.match(domain: "example.com", method: :get, path: "/users")
242
+ Aris::Router.match(domain: "example.com", method: :get, path: "/users/")
243
+ ```
244
+
245
+ Second, both domains and paths are converted to lowercase. This makes routing case-insensitive, which is generally what you want for URLs.
246
+
247
+ ```ruby
248
+ # These also match the same route
249
+ Aris::Router.match(domain: "example.com", method: :get, path: "/users")
250
+ Aris::Router.match(domain: "EXAMPLE.COM", method: :get, path: "/USERS")
251
+ ```
252
+
253
+ Third, URI encoding is decoded automatically. If someone sends a request with URL-encoded characters, Aris decodes them before matching.
254
+
255
+ ```ruby
256
+ # URL-encoded space becomes a real space
257
+ result = Aris::Router.match(
258
+ domain: "example.com",
259
+ method: :get,
260
+ path: "/search/hello%20world"
261
+ )
262
+
263
+ result[:params][:query] # => "hello world"
264
+ ```
265
+
266
+ These normalizations happen transparently. You define routes using normal syntax and let Aris handle the edge cases.
267
+
268
+ ### Priority and Precedence
269
+
270
+ When multiple routes could potentially match a request, Aris uses a strict priority system to choose which one wins. Understanding this system helps you write predictable routes and avoid surprises.
271
+
272
+ The priority order is: literal segments beat parameterized segments, and parameterized segments beat wildcards.
273
+
274
+ ```ruby
275
+ Aris.routes({
276
+ "example.com": {
277
+ "/users": {
278
+ "/new": { get: { to: NewUserHandler } },
279
+ "/:id": { get: { to: ShowUserHandler } }
280
+ }
281
+ }
282
+ })
283
+
284
+ # Matches NewUserHandler (literal wins)
285
+ Aris::Router.match(domain: "example.com", method: :get, path: "/users/new")
286
+
287
+ # Matches ShowUserHandler (parameter matches anything else)
288
+ Aris::Router.match(domain: "example.com", method: :get, path: "/users/123")
289
+ ```
290
+
291
+ This priority system means you can safely add specific routes without worrying about them being shadowed by more general ones. The literal `/new` route will always match before the parameterized `/:id` route, regardless of the order you define them in the configuration hash.
292
+
293
+ ---
294
+
295
+ ## Path and URL Generation
296
+
297
+ Matching routes is only half the story. Most applications also need to generate URLs from route definitions. This is where named routes prove their value.
298
+
299
+ ### Basic Path Generation
300
+
301
+ The `Aris.path` method generates relative paths from named routes. It requires either an explicit domain or a domain context, plus the route name and any necessary parameters.
302
+
303
+ ```ruby
304
+ Aris.routes({
305
+ "example.com": {
306
+ "/users/:id": {
307
+ get: { to: UserHandler, as: :user }
308
+ }
309
+ }
310
+ })
311
+
312
+ # Explicit domain (always works)
313
+ Aris.path("example.com", :user, id: 123)
314
+ # => "/users/123"
315
+ ```
316
+
317
+ The method performs several validations. If the named route does not exist, you will get a `RouteNotFoundError`. If you are missing required parameters, you will get an `ArgumentError`. These errors happen immediately, making it easy to catch mistakes during development.
318
+
319
+ ```ruby
320
+ # Missing required parameter
321
+ Aris.path("example.com", :user)
322
+ # => ArgumentError: Missing required param 'id' for route :user
323
+
324
+ # Nonexistent route
325
+ Aris.path("example.com", :nonexistent)
326
+ # => Aris::Router::RouteNotFoundError: Named route :nonexistent not found...
327
+ ```
328
+
329
+ ### Query Parameters
330
+
331
+ Any parameters you provide that are not used in the path itself become query string parameters automatically.
332
+
333
+ ```ruby
334
+ Aris.routes({
335
+ "example.com": {
336
+ "/search": {
337
+ get: { to: SearchHandler, as: :search }
338
+ }
339
+ }
340
+ })
341
+
342
+ Aris.path("example.com", :search, q: "ruby", page: 2, limit: 20)
343
+ # => "/search?q=ruby&page=2&limit=20"
344
+ ```
345
+
346
+ This behavior makes it easy to build search interfaces and paginated collections. You provide all the parameters you need, and Aris figures out which go in the path and which go in the query string.
347
+
348
+ ### Implicit Domain Context
349
+
350
+ Specifying the domain every time can be tedious, especially in request handlers where the domain is usually obvious. Aris supports implicit domain context through two mechanisms: a global default domain and thread-local context.
351
+
352
+ The global default domain is set once and applies everywhere unless overridden.
353
+
354
+ ```ruby
355
+ Aris::Router.default_domain = "example.com"
356
+
357
+ # Now this works without specifying the domain
358
+ Aris.path(:user, id: 123)
359
+ # => "/users/123"
360
+ ```
361
+
362
+ Thread-local context is more dynamic. It is set per-request and automatically cleaned up, making it ideal for web applications where different requests might target different domains.
363
+
364
+ ```ruby
365
+ # Set context for this thread
366
+ Thread.current[:aris_current_domain] = "admin.example.com"
367
+
368
+ # Paths use the thread-local domain
369
+ Aris.path(:dashboard) # Uses admin.example.com
370
+
371
+ # Context is thread-safe—other threads are unaffected
372
+ ```
373
+
374
+ In Rack applications, Aris sets the thread-local domain automatically based on the incoming request. This means path generation "just works" in request handlers without any manual setup.
375
+
376
+ ### The with_domain Helper
377
+
378
+ For temporary context switches, the `with_domain` helper provides a clean block-based interface.
379
+
380
+ ```ruby
381
+ Aris.with_domain("admin.example.com") do
382
+ Aris.path(:dashboard) # Uses admin.example.com
383
+ Aris.path(:users) # Still uses admin.example.com
384
+ end
385
+
386
+ # Outside the block, we're back to the default domain
387
+ ```
388
+
389
+ This is particularly useful when generating URLs for emails or background jobs, where you need to produce links for a specific domain but do not want to change the global context.
390
+
391
+ ### URL Generation
392
+
393
+ The `Aris.url` method works identically to `Aris.path` but returns absolute URLs instead of relative paths.
394
+
395
+ ```ruby
396
+ Aris.url("api.example.com", :users)
397
+ # => "https://api.example.com/users"
398
+
399
+ Aris.url("api.example.com", :user, id: 123, protocol: 'http')
400
+ # => "http://api.example.com/users/123"
401
+ ```
402
+
403
+ The protocol defaults to `https` but can be overridden with the `protocol:` keyword argument. This is useful for development environments where you might be running on plain HTTP.
404
+
405
+ ### Static Asset Handling
406
+
407
+ ```ruby
408
+ # Enable in development (disabled by default)
409
+ Aris.configure do |c|
410
+ c.serve_static = ENV['RACK_ENV'] != 'production'
411
+
412
+ # Optional: Add custom MIME types
413
+ c.mime_types = {
414
+ '.webm' => 'video/webm',
415
+ '.flac' => 'audio/flac'
416
+ }
417
+ end
418
+ ```
419
+
420
+
421
+ ---
422
+
423
+ ## HTTP Methods
424
+
425
+ Aris supports the five standard HTTP methods: GET, POST, PUT, PATCH, and DELETE. Each method is defined separately in your route configuration, allowing you to assign different handlers to different methods on the same path.
426
+
427
+ ### Multiple Methods on One Path
428
+
429
+ It is common for a single resource to support multiple operations. A user resource might support GET to view, PUT to update, and DELETE to remove.
430
+
431
+ ```ruby
432
+ Aris.routes({
433
+ "example.com": {
434
+ "/users/:id": {
435
+ get: { to: UserShowHandler, as: :user },
436
+ put: { to: UserUpdateHandler, as: :user_update },
437
+ delete: { to: UserDeleteHandler, as: :user_delete }
438
+ }
439
+ }
440
+ })
441
+ ```
442
+
443
+ When a request comes in for `/users/123`, Aris first matches the path, then checks if a handler exists for the specific HTTP method. If the path matches but the method does not, the match returns nil, just as if the path had not matched at all.
444
+
445
+ This behavior gives you fine-grained control over what operations are allowed on each resource. You might expose read-only access on your public API but allow full CRUD operations on your admin API by simply defining different methods on similar paths across different domains.
446
+
447
+ ### RESTful Resource Routing
448
+
449
+ While Aris does not include Rails-style resource generators, the hash structure makes it straightforward to define RESTful resources manually.
450
+
451
+ ```ruby
452
+ Aris.routes({
453
+ "example.com": {
454
+ "/posts": {
455
+ get: { to: PostsIndexHandler, as: :posts },
456
+ post: { to: PostsCreateHandler, as: :posts_create },
457
+
458
+ "/:id": {
459
+ get: { to: PostShowHandler, as: :post },
460
+ put: { to: PostUpdateHandler, as: :post_update },
461
+ patch: { to: PostPatchHandler, as: :post_patch },
462
+ delete: { to: PostDeleteHandler, as: :post_delete }
463
+ },
464
+
465
+ "/new": {
466
+ get: { to: PostNewHandler, as: :post_new }
467
+ },
468
+
469
+ "/:id/edit": {
470
+ get: { to: PostEditHandler, as: :post_edit }
471
+ }
472
+ }
473
+ }
474
+ })
475
+ ```
476
+
477
+ This gives you complete control over exactly which routes exist and how they are named. There is no magic generation, but also no hidden routes that you did not explicitly define.
478
+
479
+ ### Method-Specific Named Routes
480
+
481
+ Notice in the examples above that each method gets its own name. This is optional but recommended. It makes your intent explicit and gives you maximum flexibility when generating URLs.
482
+
483
+ ```ruby
484
+ # Update form might POST to a different endpoint than the show page
485
+ Aris.url(:post) # => "https://example.com/posts/123"
486
+ Aris.url(:post_update) # => "https://example.com/posts/123"
487
+
488
+ # But you can use different methods
489
+ [200, {}, [form_for(post, url: Aris.url(:post_update), method: :put)]]
490
+ ```
491
+
492
+ If you prefer, you can share names across methods, but this limits your ability to generate method-specific URLs. The choice depends on your application's needs.
493
+
494
+ ---
495
+
496
+ ## Parameters and Wildcards
497
+
498
+ Parameters and wildcards are the two mechanisms for capturing variable content from request paths. Understanding the differences between them helps you choose the right tool for each situation.
499
+
500
+ ### Parameters
501
+
502
+ Parameters capture a single path segment. They are defined with a leading colon and must match exactly one segment—they will not match multiple segments or empty values.
503
+
504
+ ```ruby
505
+ Aris.routes({
506
+ "example.com": {
507
+ "/posts/:year/:month/:day": {
508
+ get: { to: PostsByDateHandler }
509
+ }
510
+ }
511
+ })
512
+
513
+ # Matches - three segments provided
514
+ result = Aris::Router.match(
515
+ domain: "example.com",
516
+ method: :get,
517
+ path: "/posts/2024/03/15"
518
+ )
519
+ result[:params] # => { year: "2024", month: "03", day: "15" }
520
+
521
+ # Does not match - only two segments
522
+ result = Aris::Router.match(
523
+ domain: "example.com",
524
+ method: :get,
525
+ path: "/posts/2024/03"
526
+ )
527
+ result # => nil
528
+ ```
529
+
530
+ Parameter names can be anything that forms a valid Ruby symbol. Be descriptive—`:id` is fine for simple cases, but `:user_id` or `:post_id` is clearer when you have nested resources.
531
+
532
+ ```ruby
533
+ Aris.routes({
534
+ "example.com": {
535
+ "/users/:user_id/posts/:post_id/comments/:comment_id": {
536
+ get: { to: CommentHandler }
537
+ }
538
+ }
539
+ })
540
+
541
+ result = Aris::Router.match(
542
+ domain: "example.com",
543
+ method: :get,
544
+ path: "/users/1/posts/2/comments/3"
545
+ )
546
+
547
+ result[:params]
548
+ # => { user_id: "1", post_id: "2", comment_id: "3" }
549
+ ```
550
+
551
+ ### Wildcards
552
+
553
+ Wildcards capture multiple path segments into a single parameter. They are defined with a leading asterisk and will match zero or more segments.
554
+
555
+ ```ruby
556
+ Aris.routes({
557
+ "example.com": {
558
+ "/files/*path": {
559
+ get: { to: FileHandler }
560
+ }
561
+ }
562
+ })
563
+
564
+ # Matches a deep path
565
+ result = Aris::Router.match(
566
+ domain: "example.com",
567
+ method: :get,
568
+ path: "/files/documents/2024/reports/summary.pdf"
569
+ )
570
+ result[:params]
571
+ # => { path: "documents/2024/reports/summary.pdf" }
572
+
573
+ # Also matches shallow paths
574
+ result = Aris::Router.match(
575
+ domain: "example.com",
576
+ method: :get,
577
+ path: "/files/readme.txt"
578
+ )
579
+ result[:params] # => { path: "readme.txt" }
580
+
581
+ # Even matches no segments
582
+ result = Aris::Router.match(
583
+ domain: "example.com",
584
+ method: :get,
585
+ path: "/files/"
586
+ )
587
+ result[:params] # => { path: "" }
588
+ ```
589
+
590
+ The captured value is a string with forward slashes intact. Your handler is responsible for splitting it or otherwise processing it as needed.
591
+
592
+ Wildcards can also appear in the middle of a path, which is useful for versioned APIs or other creative routing patterns.
593
+
594
+ ```ruby
595
+ Aris.routes({
596
+ "api.example.com": {
597
+ "/*version/users": {
598
+ get: { to: ApiUsersHandler }
599
+ }
600
+ }
601
+ })
602
+
603
+ result = Aris::Router.match(
604
+ domain: "api.example.com",
605
+ method: :get,
606
+ path: "/v1/users"
607
+ )
608
+ result[:params] # => { version: "v1" }
609
+
610
+ result = Aris::Router.match(
611
+ domain: "api.example.com",
612
+ method: :get,
613
+ path: "/v2/beta/users"
614
+ )
615
+ result[:params] # => { version: "v2/beta" }
616
+ ```
617
+
618
+ ### Anonymous Wildcards
619
+
620
+ If you do not need to capture the wildcard value, you can use a bare asterisk without a name. This creates a catch-all route that matches anything but does not add to the params hash.
621
+
622
+ ```ruby
623
+ Aris.routes({
624
+ "example.com": {
625
+ "/*": {
626
+ get: { to: CatchAllHandler }
627
+ }
628
+ }
629
+ })
630
+ ```
631
+
632
+ However, named wildcards are usually clearer. Even if you do not use the captured value immediately, having it in the params hash makes debugging easier and keeps future options open.
633
+
634
+ ---
635
+
636
+ ## File-Based Route Discovery
637
+
638
+ Instead of defining routes in a hash, you can organize them as files in a directory structure. Aris will scan the directory and automatically generate the route definitions.
639
+
640
+ ### Directory Convention
641
+
642
+ The directory structure maps directly to routes:
643
+
644
+ ```
645
+ routes_dir/
646
+ domain/ # Domain name (use _ for wildcard)
647
+ path/ # Path segments
648
+ _param/ # Parameters (prefix with _)
649
+ method.rb # HTTP method (get, post, put, etc.)
650
+ ```
651
+
652
+ ### Examples
653
+
654
+ **Simple route:**
655
+ ```
656
+ app/routes/example.com/index/get.rb → GET / on example.com
657
+ ```
658
+
659
+ **Parameterized route:**
660
+ ```
661
+ app/routes/example.com/users/_id/get.rb → GET /users/:id on example.com
662
+ ```
663
+
664
+ **Nested parameters:**
665
+ ```
666
+ app/routes/example.com/users/_user_id/posts/_post_id/get.rb
667
+ → GET /users/:user_id/posts/:post_id on example.com
668
+ ```
669
+
670
+ **Wildcard domain:**
671
+ ```
672
+ app/routes/_/health/get.rb → GET /health on * (any domain)
673
+ ```
674
+
675
+ **Multiple HTTP methods:**
676
+ ```
677
+ app/routes/example.com/users/get.rb → GET /users
678
+ app/routes/example.com/users/post.rb → POST /users
679
+ ```
680
+
681
+ ### Handler Definition
682
+
683
+ Each route file must define a `Handler` class with a `.call` class method:
684
+
685
+ ```ruby
686
+ # app/routes/example.com/users/_id/get.rb
687
+ class Handler
688
+ def self.call(request, params)
689
+ user_id = params[:id]
690
+ user = User.find(user_id)
691
+
692
+ return Aris.not_found(request) unless user
693
+
694
+ { id: user.id, name: user.name, email: user.email }
695
+ end
696
+ end
697
+ ```
698
+
699
+ Handlers can return:
700
+ - **Hash/Array**: Automatically converted to JSON
701
+ - **String**: Returned as plain text
702
+ - **Rack response**: `[status, headers, body]` array
703
+
704
+ ### Loading Routes
705
+
706
+ Use `Aris.discover_and_define` at boot time:
707
+
708
+ ```ruby
709
+ # config.ru
710
+ require 'aris'
711
+
712
+ Aris.discover_and_define('app/routes')
713
+
714
+ run Aris::Adapters::RackApp.new
715
+ ```
716
+
717
+ Or discover first and merge with explicit routes:
718
+
719
+ ```ruby
720
+ discovered = Aris::Discovery.discover('app/routes')
721
+
722
+ explicit = {
723
+ "example.com": {
724
+ "/admin": {
725
+ use: [:admin_auth],
726
+ "/dashboard": { get: { to: AdminDashboard } }
727
+ }
728
+ }
729
+ }
730
+
731
+ # Explicit routes can override or extend discovered routes
732
+ Aris.routes(explicit.merge(discovered))
733
+ ```
734
+
735
+ ### Supported HTTP Methods
736
+
737
+ Discovery recognizes these HTTP methods as filenames:
738
+ - `get.rb`
739
+ - `post.rb`
740
+ - `put.rb`
741
+ - `patch.rb`
742
+ - `delete.rb`
743
+ - `options.rb`
744
+
745
+ Files with other names (e.g., `invalid.rb`) are ignored.
746
+
747
+ ### Special Cases
748
+
749
+ **Index files**: A file named `index` represents the root of that path segment.
750
+
751
+ ```ruby
752
+ # app/routes/example.com/index/get.rb maps to GET /
753
+ # app/routes/example.com/users/index/get.rb maps to GET /users
754
+ ```
755
+
756
+ **Domain names**: Use the actual domain name or `_` for wildcard:
757
+
758
+ ```ruby
759
+ # app/routes/example.com/ → Routes for example.com
760
+ # app/routes/api.example.com/ → Routes for api.example.com
761
+ # app/routes/_/ → Routes for * (any domain)
762
+ ```
763
+
764
+ **Parameters**: Prefix directory names with `_` to indicate parameters:
765
+
766
+ ```ruby
767
+ # app/routes/example.com/users/_id/posts/_post_id/get.rb
768
+ # The _id becomes :id parameter
769
+ # The _post_id becomes :post_id parameter
770
+ ```
771
+
772
+ ### Handler Namespacing
773
+
774
+ To prevent conflicts, each handler is automatically namespaced based on its location:
775
+
776
+ ```ruby
777
+ # app/routes/example.com/users/get.rb
778
+ # Creates: ExampleCom::Users::Get::Handler
779
+
780
+ # app/routes/example.com/posts/get.rb
781
+ # Creates: ExampleCom::Posts::Get::Handler
782
+ ```
783
+
784
+ This allows multiple routes to define a `Handler` class without conflicts.
785
+
786
+ ### Development vs Production
787
+
788
+ **Development mode** - Reload routes on file changes:
789
+
790
+ ```ruby
791
+ if ENV['RACK_ENV'] == 'development'
792
+ require 'listen'
793
+
794
+ listener = Listen.to('app/routes') do |modified, added, removed|
795
+ puts "Routes changed, reloading..."
796
+ Aris.discover_and_define('app/routes')
797
+ end
798
+
799
+ listener.start
800
+ end
801
+ ```
802
+
803
+ **Production mode** - Load once at boot:
804
+
805
+ ```ruby
806
+ # Discovery happens once during boot
807
+ Aris.discover_and_define('app/routes')
808
+ # Handlers are compiled into the route trie
809
+ # No file I/O during request handling
810
+ ```
811
+
812
+ ### Performance Considerations
813
+
814
+ File-based discovery happens **once at boot time**, not at request time. After discovery:
815
+
816
+ - Handlers are loaded into memory as Ruby classes
817
+ - Routes are compiled into Aris's optimized trie structure
818
+ - Request handling is identical to hash-defined routes (570ns-1.31μs per match)
819
+ - No file I/O or dynamic loading during requests
820
+
821
+ Discovery time scales linearly: approximately 0.2-0.5ms per route. For a typical app with 100 routes, discovery takes 20-50ms at boot.
822
+
823
+ ### Error Handling
824
+
825
+ Discovery gracefully handles errors:
826
+
827
+ **Missing Handler constant:**
828
+ ```ruby
829
+ # File doesn't define Handler class
830
+ # Warning logged, route skipped
831
+ ```
832
+
833
+ **Syntax errors:**
834
+ ```ruby
835
+ # File has invalid Ruby syntax
836
+ # Warning logged, route skipped
837
+ ```
838
+
839
+ **Handler without .call method:**
840
+ ```ruby
841
+ # Handler class doesn't respond to .call
842
+ # Warning logged, route skipped
843
+ ```
844
+
845
+ Check your logs at boot time for any warnings about skipped routes.
846
+
847
+ ### Testing
848
+
849
+ Test handlers in isolation:
850
+
851
+ ```ruby
852
+ # test/routes/users_get_test.rb
853
+ require 'test_helper'
854
+
855
+ # Load the handler directly
856
+ require_relative '../../app/routes/example.com/users/get'
857
+
858
+ class UsersGetTest < Minitest::Test
859
+ def test_returns_user_list
860
+ # Handler is namespaced
861
+ result = ExampleCom::Users::Get::Handler.call(mock_request, {})
862
+
863
+ assert_kind_of Array, result
864
+ assert result.any?
865
+ end
866
+ end
867
+ ```
868
+
869
+ Or test through the router:
870
+
871
+ ```ruby
872
+ def test_users_route
873
+ Aris.discover_and_define('app/routes')
874
+
875
+ result = Aris::Router.match(
876
+ domain: "example.com",
877
+ method: :get,
878
+ path: "/users"
879
+ )
880
+
881
+ assert result
882
+ assert_respond_to result[:handler], :call
883
+ end
884
+ ```
885
+
886
+ ### Comparison with Hash Definition
887
+
888
+ **File-based (discovery):**
889
+ ```
890
+ Pros:
891
+ - Organized by domain and path
892
+ - Easy to find handlers
893
+ - Scales well with many routes
894
+ - Clear file-per-route structure
895
+
896
+ Cons:
897
+ - Slightly slower boot time (0.2-0.5ms per route)
898
+ - Requires file system
899
+ ```
900
+
901
+ **Hash-based (explicit):**
902
+ ```
903
+ Pros:
904
+ - Instant definition (no file I/ O)
905
+ - Can use dynamic handler creation
906
+ - All routes visible in one place
907
+
908
+ Cons:
909
+ - Large hash for many routes
910
+ - Harder to navigate
911
+ ```
912
+
913
+ Most apps benefit from using both: file-based discovery for standard routes, hash definition for special cases or dynamic routes.
914
+
915
+ ---
916
+
917
+ ## Constraints
918
+
919
+ Constraints validate parameter values at the routing level, before any handler code runs. This creates a fail-fast system where invalid requests never reach your application logic.
920
+
921
+ ### Basic Constraints
922
+
923
+ Constraints are defined with the `constraints:` option and use regular expressions to match parameter values.
924
+
925
+ ```ruby
926
+ Aris.routes({
927
+ "example.com": {
928
+ "/users/:id": {
929
+ get: {
930
+ to: UserHandler,
931
+ as: :user,
932
+ constraints: { id: /\A\d{1,8}\z/ } # 1-8 digit numbers only
933
+ }
934
+ }
935
+ }
936
+ })
937
+
938
+ # Matches - valid numeric ID
939
+ result = Aris::Router.match(
940
+ domain: "example.com",
941
+ method: :get,
942
+ path: "/users/12345"
943
+ )
944
+ result[:handler] # => UserHandler
945
+
946
+ # Does not match - alphabetic characters
947
+ result = Aris::Router.match(
948
+ domain: "example.com",
949
+ method: :get,
950
+ path: "/users/admin"
951
+ )
952
+ result # => nil
953
+
954
+ # Does not match - too many digits
955
+ result = Aris::Router.match(
956
+ domain: "example.com",
957
+ method: :get,
958
+ path: "/users/123456789"
959
+ )
960
+ result # => nil
961
+ ```
962
+
963
+ When a constraint fails, the entire route fails. Aris will try other routes that might match the path, respecting the usual priority rules. If no routes match, the match returns nil, just as if the path had not matched in the first place.
964
+
965
+ ### Multiple Constraints
966
+
967
+ You can constrain multiple parameters in a single route. Each parameter is validated independently.
968
+
969
+ ```ruby
970
+ Aris.routes({
971
+ "example.com": {
972
+ "/posts/:year/:month/:day": {
973
+ get: {
974
+ to: PostsByDateHandler,
975
+ constraints: {
976
+ year: /\A\d{4}\z/, # Four-digit year
977
+ month: /\A(0[1-9]|1[0-2])\z/, # 01-12
978
+ day: /\A(0[1-9]|[12]\d|3[01])\z/ # 01-31
979
+ }
980
+ }
981
+ }
982
+ }
983
+ })
984
+
985
+ # Matches - all constraints pass
986
+ result = Aris::Router.match(
987
+ domain: "example.com",
988
+ method: :get,
989
+ path: "/posts/2024/03/15"
990
+ )
991
+ result[:params] # => { year: "2024", month: "03", day: "15" }
992
+
993
+ # Does not match - invalid month
994
+ result = Aris::Router.match(
995
+ domain: "example.com",
996
+ method: :get,
997
+ path: "/posts/2024/13/15"
998
+ )
999
+ result # => nil
1000
+ ```
1001
+
1002
+ All constraints must pass for the route to match. If even one fails, the entire route is rejected.
1003
+
1004
+ ### Constraints and Route Priority
1005
+
1006
+ Constraints are evaluated after path structure matches but before the route is considered valid. This means you can have multiple routes with the same path structure but different constraints, and Aris will try them in priority order.
1007
+
1008
+ ```ruby
1009
+ Aris.routes({
1010
+ "example.com": {
1011
+ "/users": {
1012
+ "/:id": {
1013
+ get: {
1014
+ to: NumericUserHandler,
1015
+ constraints: { id: /\A\d+\z/ }
1016
+ }
1017
+ },
1018
+ "/:username": {
1019
+ get: {
1020
+ to: UsernameUserHandler,
1021
+ constraints: { username: /\A[a-z]+\z/ }
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+ })
1027
+ ```
1028
+
1029
+ However, this pattern is tricky and not generally recommended. Because both routes have the same path structure (`/:id` and `/:username` both match a single segment), Aris treats them as the same route structurally. Only one will actually be used, based on the order they appear in the hash.
1030
+
1031
+ A better approach is to use distinct path structures:
1032
+
1033
+ ```ruby
1034
+ Aris.routes({
1035
+ "example.com": {
1036
+ "/users": {
1037
+ "/id/:id": {
1038
+ get: {
1039
+ to: NumericUserHandler,
1040
+ constraints: { id: /\A\d+\z/ }
1041
+ }
1042
+ },
1043
+ "/username/:username": {
1044
+ get: {
1045
+ to: UsernameUserHandler,
1046
+ constraints: { username: /\A[a-z]+\z/ }
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ })
1052
+ ```
1053
+
1054
+ This makes the intent explicit and avoids any ambiguity about which route should match.
1055
+
1056
+ ### Common Constraint Patterns
1057
+
1058
+ Here are some useful constraint patterns for common validation needs:
1059
+
1060
+ ```ruby
1061
+ # Numeric IDs
1062
+ id: /\A\d+\z/
1063
+
1064
+ # Limited-length numeric IDs (prevents very large numbers)
1065
+ id: /\A\d{1,10}\z/
1066
+
1067
+ # URL-safe slugs
1068
+ slug: /\A[a-z0-9\-]+\z/
1069
+
1070
+ # Uppercase codes (like country codes)
1071
+ country: /\A[A-Z]{2}\z/
1072
+
1073
+ # UUIDs
1074
+ uuid: /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
1075
+
1076
+ # Alphanumeric tokens
1077
+ token: /\A[a-zA-Z0-9]{20,}\z/
1078
+
1079
+ # Dates in YYYY-MM-DD format
1080
+ date: /\A\d{4}-\d{2}-\d{2}\z/
1081
+ ```
1082
+
1083
+ These patterns provide a first line of defense against malformed input, but they should not be your only validation. Always validate data again in your handlers, especially for business logic constraints that go beyond format validation.
1084
+
1085
+ ---
1086
+
1087
+ ## Multi-Domain Routing
1088
+
1089
+ Modern web applications often span multiple domains. Your marketing site might be on `example.com`, your app on `app.example.com`, your API on `api.example.com`, and your admin panel on `admin.example.com`. Aris treats domains as first-class routing primitives, making multi-domain applications straightforward to build.
1090
+
1091
+ ### Domain-Level Isolation
1092
+
1093
+ Each domain in your routing configuration gets its own independent routing tree. Routes defined on one domain do not interfere with routes on another domain, even if they have identical paths.
1094
+
1095
+ ```ruby
1096
+ Aris.routes({
1097
+ "example.com": {
1098
+ "/": { get: { to: MarketingHomeHandler } },
1099
+ "/pricing": { get: { to: PricingHandler } },
1100
+ "/contact": { get: { to: ContactHandler } }
1101
+ },
1102
+
1103
+ "app.example.com": {
1104
+ "/": { get: { to: AppDashboardHandler } },
1105
+ "/projects": { get: { to: ProjectsHandler } },
1106
+ "/settings": { get: { to: SettingsHandler } }
1107
+ },
1108
+
1109
+ "api.example.com": {
1110
+ "/v1": {
1111
+ "/users": { get: { to: ApiUsersHandler } },
1112
+ "/projects": { get: { to: ApiProjectsHandler } }
1113
+ }
1114
+ }
1115
+ })
1116
+ ```
1117
+
1118
+ Notice that both `example.com` and `app.example.com` have a root route, and both `app.example.com` and `api.example.com` have a `/projects` route. These do not conflict because the domain is always part of the routing decision.
1119
+
1120
+ When a request comes in, Aris first looks up the domain in the routing table. If found, it searches that domain's tree for a matching path. If not found, it falls back to the wildcard domain (if one is defined). The domain is never ambiguous.
1121
+
1122
+ ### Handling Multi-Tenant Subdomains
1123
+
1124
+ For applications where each tenant gets their own subdomain (like `tenant-a.acme.com` and `tenant-b.acme.com`), you should not define a separate route for every tenant. Instead, route to a general domain and extract the tenant identifier from the hostname in your application code.
1125
+
1126
+ Aris does not support dynamic wildcard domains in route definitions (you cannot use `*.acme.com` as a domain key). However, you can achieve multi-tenant subdomain routing by pointing all tenant traffic to a single domain endpoint via DNS configuration, then processing the full hostname in your handlers or plugins.
1127
+
1128
+ ```ruby
1129
+ # All tenant traffic points to this domain via DNS CNAME
1130
+ Aris.routes({
1131
+ "app.acme.com": {
1132
+ "/": { get: { to: TenantDashboardHandler } },
1133
+ "/settings": { get: { to: TenantSettingsHandler } }
1134
+ }
1135
+ })
1136
+
1137
+ # In your handler or a plugin:
1138
+ class TenantDashboardHandler
1139
+ def self.call(request, params)
1140
+ # request.domain contains the full hostname: "tenant-a.acme.com"
1141
+ subdomain = request.domain.split('.').first
1142
+ tenant = Tenant.find_by(subdomain: subdomain)
1143
+
1144
+ return Aris.not_found(request) unless tenant
1145
+
1146
+ # Use tenant data to customize the response
1147
+ [200, {}, ["Welcome to #{tenant.name}'s Dashboard"]]
1148
+ end
1149
+ end
1150
+ ```
1151
+
1152
+ This pattern keeps your routing configuration clean and static while allowing for infinite dynamic subdomains handled by your application logic. The routing structure stays simple, and tenant-specific behavior lives in your handlers where it belongs.
1153
+
1154
+ ### The Wildcard Domain Fallback
1155
+
1156
+ The special `"*"` domain acts as a catch-all for requests that do not match any specific domain. This is perfect for infrastructure routes like health checks that should work on any domain.
1157
+
1158
+ ```ruby
1159
+ Aris.routes({
1160
+ "example.com": {
1161
+ "/": { get: { to: HomeHandler } }
1162
+ },
1163
+
1164
+ "app.example.com": {
1165
+ "/dashboard": { get: { to: DashboardHandler } }
1166
+ },
1167
+
1168
+ "*": {
1169
+ "/health": { get: { to: HealthCheckHandler } },
1170
+ "/version": { get: { to: VersionHandler } }
1171
+ }
1172
+ })
1173
+ ```
1174
+
1175
+ Now `/health` and `/version` will work on any domain—`example.com/health`, `app.example.com/health`, `unknown.example.com/health`, and so on. The wildcard domain is checked only if no specific domain matches.
1176
+
1177
+ This fallback behavior is important to understand. If you define a `/health` route on `example.com` and also on `"*"`, a request to `example.com/health` will match the specific domain route, not the wildcard. Specific domains always take precedence.
1178
+
1179
+ ### Cross-Domain URL Generation
1180
+
1181
+ When generating URLs in a multi-domain application, you typically need to specify which domain you are targeting. This is where explicit domain in path helpers shines.
1182
+
1183
+ ```ruby
1184
+ # Generate URLs for different domains
1185
+ marketing_url = Aris.url("example.com", :home)
1186
+ # => "https://example.com/"
1187
+
1188
+ app_url = Aris.url("app.example.com", :dashboard)
1189
+ # => "https://app.example.com/dashboard"
1190
+
1191
+ api_url = Aris.url("api.example.com", :users)
1192
+ # => "https://api.example.com/v1/users"
1193
+ ```
1194
+
1195
+ In request handlers, you can use the thread-local domain context to default to the current domain while still being able to generate cross-domain links when needed.
1196
+
1197
+ ```ruby
1198
+ class DashboardHandler
1199
+ def self.call(request, params)
1200
+ # Current domain link (uses thread-local context)
1201
+ settings_link = Aris.path(:settings)
1202
+
1203
+ # Cross-domain link (explicit domain)
1204
+ api_link = Aris.url("api.example.com", :users)
1205
+
1206
+ [200, {}, ["Dashboard with links"]]
1207
+ end
1208
+ end
1209
+ ```
1210
+
1211
+ ---
1212
+
1213
+ ## The Plugin System
1214
+
1215
+ Plugins (also called middleware or filters in other frameworks) let you run code before your handlers execute. They are perfect for cross-cutting concerns like authentication, logging, rate limiting, and response modification.
1216
+
1217
+ ### Plugin Basics
1218
+
1219
+ A plugin is any callable object that implements `call(request, response)`. It receives the current request and a mutable response object. It can inspect the request, modify the response, or halt processing entirely by returning the response object.
1220
+
1221
+ ```ruby
1222
+ class SimpleLogger
1223
+ def self.call(request, response)
1224
+ puts "Request: #{request.method} #{request.path}"
1225
+ nil # Return nil to continue processing
1226
+ end
1227
+ end
1228
+
1229
+ class Authentication
1230
+ def self.call(request, response)
1231
+ token = request.headers['HTTP_AUTHORIZATION']
1232
+
1233
+ if token != 'valid-token'
1234
+ response.status = 401
1235
+ response.body = ['Unauthorized']
1236
+ return response # Return response to halt processing
1237
+ end
1238
+
1239
+ nil # Return nil to continue
1240
+ end
1241
+ end
1242
+ ```
1243
+
1244
+ The contract is simple: if you return nil (or anything that is not a response object), processing continues to the next plugin or handler. If you return a response object, processing stops immediately and that response is sent to the client.
1245
+
1246
+
1247
+ ### Registering Plugins
1248
+
1249
+ Before using plugins in routes, register them with a symbol name. This lets you reference plugins cleanly in your route configuration.
1250
+
1251
+ ```ruby
1252
+ # Register a single plugin
1253
+ class RateLimiter
1254
+ def self.call(request, response)
1255
+ # Implementation
1256
+ end
1257
+ end
1258
+
1259
+ Aris.register_plugin(:rate_limit, plugin_class: RateLimiter)
1260
+
1261
+ # Register multi-class plugins (like CSRF with separate generator and protection)
1262
+ Aris.register_plugin(:csrf,
1263
+ generator: CsrfTokenGenerator,
1264
+ protection: CsrfProtection
1265
+ )
1266
+
1267
+ # Now use them in routes via symbols
1268
+ Aris.routes({
1269
+ "api.example.com": {
1270
+ use: [:rate_limit, :csrf], # Symbols resolve to plugin classes
1271
+ "/users": { get: { to: UsersHandler } }
1272
+ }
1273
+ })
1274
+ ```
1275
+
1276
+ Registered symbols expand to their plugin classes at compile time. Multi-class plugins like `:csrf` expand to all their components in order (generator, then protection).
1277
+
1278
+ ### Applying Plugins
1279
+
1280
+ Plugins are applied using the `use:` key at three levels: domain, scope, and route. Plugins inherit down the tree and are executed in the order they appear.
1281
+
1282
+ ```ruby
1283
+ Aris.routes({
1284
+ "api.example.com": {
1285
+ use: [:cors, :rate_limit], # Domain-level - using registered symbols
1286
+
1287
+ "/public": {
1288
+ "/status": { get: { to: StatusHandler } }
1289
+ },
1290
+
1291
+ "/private": {
1292
+ use: [:authentication], # Scope-level (inherits :cors, :rate_limit)
1293
+ "/users": { get: { to: UsersHandler } }
1294
+ }
1295
+ }
1296
+ })
1297
+ ```
1298
+
1299
+ In this example:
1300
+ - Requests to `/public/status` run through CORS and rate limiting plugins
1301
+ - Requests to `/private/users` run through CORS, rate limiting, and authentication plugins
1302
+
1303
+ The scope-level plugin adds to the domain-level plugins rather than replacing them. This composition model makes it easy to build layered security and functionality.
1304
+
1305
+ ### Route-Level Plugins
1306
+
1307
+ You can also apply plugins to individual routes. Route-level plugins merge with inherited plugins.
1308
+
1309
+ ```ruby
1310
+ Aris.routes({
1311
+ "example.com": {
1312
+ use: [CorsHeaders],
1313
+
1314
+ "/users/:id": {
1315
+ get: {
1316
+ to: UserHandler,
1317
+ use: [CorsHeaders, CacheControl] # Merges with domain-level
1318
+ }
1319
+ }
1320
+ }
1321
+ })
1322
+ ```
1323
+
1324
+ A request to `/users/123` will run through `CorsHeaders` (from domain level) and `CacheControl` (from route level). The router automatically deduplicates plugins, so if the same plugin appears at multiple levels, it only runs once.
1325
+
1326
+ ### Clearing Inherited Plugins
1327
+
1328
+ Sometimes you need to opt out of inherited plugins. Health check endpoints often fall into this category—you want them to run without authentication or rate limiting so monitoring systems can reach them reliably.
1329
+
1330
+ ```ruby
1331
+ Aris.routes({
1332
+ "example.com": {
1333
+ use: [Authentication, RateLimiter],
1334
+
1335
+ "/users": {
1336
+ get: { to: UsersHandler } # Runs through Authentication, RateLimiter
1337
+ },
1338
+
1339
+ "/health": {
1340
+ use: nil, # Clears all inherited plugins
1341
+ get: { to: HealthHandler }
1342
+ }
1343
+ }
1344
+ })
1345
+ ```
1346
+
1347
+ Setting `use: nil` at any level clears all inherited plugins from that point down. The route runs with no plugins at all, as if it were defined at the top level without any `use:` keys above it.
1348
+
1349
+ ### Plugin Execution Order
1350
+
1351
+ Plugins execute in the order they appear in the combined list. Understanding this order is important when plugins depend on each other.
1352
+
1353
+ ```ruby
1354
+ Aris.routes({
1355
+ "api.example.com": {
1356
+ use: [:cors, :authentication, :rate_limit], # Using registered plugin symbols
1357
+
1358
+ "/users": { get: { to: UsersHandler } }
1359
+ }
1360
+ })
1361
+ ```
1362
+
1363
+ For a request to `/users`:
1364
+ 1. `CorsHeaders` runs first and adds CORS headers to the response
1365
+ 2. `Authentication` runs second and checks if the request is authorized
1366
+ 3. `RateLimiter` runs third and checks if the request is within rate limits
1367
+ 4. If all plugins return nil, `UsersHandler` executes
1368
+
1369
+ If any plugin returns a response object, execution stops immediately. If `Authentication` returns a 401 response, `RateLimiter` never runs and neither does the handler.
1370
+
1371
+ This early-exit behavior is powerful. It means expensive operations like rate limit checks only run for authenticated requests. You can structure your plugin order to fail fast on the cheapest checks.
1372
+
1373
+ ### Modifying the Response
1374
+
1375
+ Plugins receive a mutable response object. Any changes you make to it will be visible to subsequent plugins and the final response.
1376
+
1377
+ ```ruby
1378
+ class ResponseTimer
1379
+ def self.call(request, response)
1380
+ start_time = Time.now
1381
+
1382
+ # Store start time in the response object for later use
1383
+ response.headers['X-Request-Start'] = start_time.to_f.to_s
1384
+
1385
+ nil # Continue processing
1386
+ end
1387
+ end
1388
+
1389
+ class ResponseFinalizer
1390
+ def self.call(request, response)
1391
+ if start_time = response.headers['X-Request-Start']
1392
+ duration = Time.now.to_f - start_time.to_f
1393
+ response.headers['X-Request-Duration'] = duration.to_s
1394
+ end
1395
+
1396
+ nil # Continue processing
1397
+ end
1398
+ end
1399
+
1400
+ Aris.routes({
1401
+ "api.example.com": {
1402
+ use: [ResponseTimer, ResponseFinalizer],
1403
+ "/users": { get: { to: UsersHandler } }
1404
+ }
1405
+ })
1406
+ ```
1407
+
1408
+ This pattern of setting state in the response and reading it later is a simple way to share data between plugins without relying on global variables or thread-local state.
1409
+
1410
+ ### Plugin Patterns
1411
+
1412
+ Here are some common plugin patterns that solve real problems:
1413
+
1414
+ **Setting Headers**
1415
+
1416
+ ```ruby
1417
+ class SecurityHeaders
1418
+ def self.call(request, response)
1419
+ response.headers['X-Frame-Options'] = 'DENY'
1420
+ response.headers['X-content-type-Options'] = 'nosniff'
1421
+ response.headers['X-XSS-Protection'] = '1; mode=block'
1422
+ nil
1423
+ end
1424
+ end
1425
+ ```
1426
+
1427
+ **Content Negotiation**
1428
+
1429
+ ```ruby
1430
+ class JsonResponder
1431
+ def self.call(request, response)
1432
+ response.headers['content-type'] = 'application/json'
1433
+ nil
1434
+ end
1435
+ end
1436
+ ```
1437
+
1438
+ **Request Logging**
1439
+
1440
+ ```ruby
1441
+ class RequestLogger
1442
+ def self.call(request, response)
1443
+ RequestLog.create(
1444
+ method: request.method,
1445
+ path: request.path,
1446
+ domain: request.domain,
1447
+ timestamp: Time.now
1448
+ )
1449
+ nil
1450
+ end
1451
+ end
1452
+ ```
1453
+
1454
+ **Rate Limiting**
1455
+
1456
+ ```ruby
1457
+ class RateLimiter
1458
+ def self.call(request, response)
1459
+ key = request.headers['HTTP_X_API_KEY']
1460
+
1461
+ if rate_limit_exceeded?(key)
1462
+ response.status = 429
1463
+ response.headers['Retry-After'] = '60'
1464
+ response.body = ['Rate limit exceeded']
1465
+ return response
1466
+ end
1467
+
1468
+ increment_rate_limit(key)
1469
+ nil
1470
+ end
1471
+
1472
+ def self.rate_limit_exceeded?(key)
1473
+ # Check Redis or similar
1474
+ end
1475
+
1476
+ def self.increment_rate_limit(key)
1477
+ # Increment counter in Redis
1478
+ end
1479
+ end
1480
+ ```
1481
+
1482
+ ---
1483
+
1484
+ ## Error Handling
1485
+
1486
+ Production applications need robust error handling. Aris separates routing failures (404s) from application errors (500s) and provides declarative handlers for both.
1487
+
1488
+ ### Configuring Error Handlers
1489
+
1490
+ Error handlers are set globally using `Aris.default`. These handlers are callables that return Rack-compatible response arrays.
1491
+
1492
+ ```ruby
1493
+ class Custom404
1494
+ def self.call(request, params)
1495
+ [404,
1496
+ {'content-type' => 'application/json'},
1497
+ ['{"error": "Not found", "path": "' + request.path + '"}']]
1498
+ end
1499
+ end
1500
+
1501
+ class Custom500
1502
+ def self.call(request, exception)
1503
+ # Log to your error tracking service
1504
+ ErrorTracker.report(exception, {
1505
+ request_path: request.path,
1506
+ request_method: request.method
1507
+ })
1508
+
1509
+ # Return a safe error response
1510
+ [500,
1511
+ {'content-type' => 'application/json'},
1512
+ ['{"error": "Internal server error"}']]
1513
+ end
1514
+ end
1515
+
1516
+ Aris.default(
1517
+ not_found: Custom404,
1518
+ error: Custom500,
1519
+ default_host: 'example.com'
1520
+ )
1521
+ ```
1522
+
1523
+ Once configured, these handlers are used automatically when errors occur. You do not need to rescue exceptions in every handler or check for nil routes in every controller. The error handling is centralized and consistent.
1524
+
1525
+ ### The 404 Flow
1526
+
1527
+ A 404 occurs in two situations: when no route matches the incoming request, or when you explicitly trigger it from your application code.
1528
+
1529
+ The first case happens automatically. If `Aris::Router.match` returns nil, the Rack adapter calls your configured 404 handler.
1530
+
1531
+ ```ruby
1532
+ # No route defined for this path
1533
+ result = Aris::Router.match(
1534
+ domain: "example.com",
1535
+ method: :get,
1536
+ path: "/nonexistent"
1537
+ )
1538
+ # => nil
1539
+
1540
+ # In the Rack adapter, this triggers:
1541
+ Aris.not_found(request)
1542
+ ```
1543
+
1544
+ The second case gives you control. When your handler determines that a resource does not exist, call `Aris.not_found` to trigger the 404 handler.
1545
+
1546
+ ```ruby
1547
+ class UserHandler
1548
+ def self.call(request, params)
1549
+ user = User.find_by(id: params[:id])
1550
+
1551
+ # Explicitly trigger 404 if user not found
1552
+ return Aris.not_found(request) unless user
1553
+
1554
+ # Normal response if user exists
1555
+ [200, {}, [user.to_json]]
1556
+ end
1557
+ end
1558
+ ```
1559
+
1560
+ This pattern keeps your handlers clean. You do not need conditional logic to return different response formats—just call `not_found` and the configured handler takes care of formatting and logging.
1561
+
1562
+ ### The 500 Flow
1563
+
1564
+ A 500 occurs when an exception is raised during request processing. This includes exceptions from plugins and handlers.
1565
+
1566
+ ```ruby
1567
+ class PaymentHandler
1568
+ def self.call(request, params)
1569
+ # This might raise if the payment gateway is down
1570
+ PaymentGateway.charge(params[:amount])
1571
+ rescue PaymentGateway::Error => e
1572
+ # Explicitly trigger 500 handler
1573
+ return Aris.error(request, e)
1574
+ end
1575
+ end
1576
+ ```
1577
+
1578
+ You can also let exceptions bubble up naturally. The Rack adapter catches all unhandled exceptions and routes them to your configured 500 handler automatically.
1579
+
1580
+ ```ruby
1581
+ class DataHandler
1582
+ def self.call(request, params)
1583
+ # If this raises, Rack adapter catches it and calls error handler
1584
+ critical_operation_that_might_fail
1585
+ end
1586
+ end
1587
+ ```
1588
+
1589
+ Both approaches work. Explicitly calling `Aris.error` gives you control over which exceptions are treated as 500s versus which should crash the application. Letting exceptions bubble is simpler but less selective.
1590
+
1591
+ ### The Redirect Helper
1592
+
1593
+ The `Aris.redirect` method provides a clean way to return redirect responses from handlers.
1594
+
1595
+ ```ruby
1596
+ class LegacyUserHandler
1597
+ def self.call(request, params)
1598
+ # Redirect to the new endpoint
1599
+ Aris.redirect(:user_show, id: params[:id], status: 301)
1600
+ end
1601
+ end
1602
+ ```
1603
+
1604
+ The method accepts either a named route (as a symbol) or a string URL. It returns a Rack-compatible response array with the appropriate status code and Location header.
1605
+
1606
+ ```ruby
1607
+ # Named route redirect
1608
+ Aris.redirect(:home)
1609
+ # => [302, {'location' => 'https://example.com/'}, []]
1610
+
1611
+ # Named route with parameters
1612
+ Aris.redirect(:user, id: 123, status: 301)
1613
+ # => [301, {'location' => 'https://example.com/users/123'}, []]
1614
+
1615
+ # Direct URL redirect
1616
+ Aris.redirect('https://external-site.com/resource')
1617
+ # => [302, {'location' => 'https://external-site.com/resource'}, []]
1618
+ ```
1619
+
1620
+ The default status is 302 (temporary redirect), but you can specify any redirect status code with the `status:` keyword argument.
1621
+
1622
+ ### Error Handler Best Practices
1623
+
1624
+ Error handlers are the last line of defense. They should never raise exceptions themselves, or you risk crashing the application with no way to recover.
1625
+
1626
+ Always include fallback logic in your error handlers:
1627
+
1628
+ ```ruby
1629
+ class SafeErrorHandler
1630
+ def self.call(request, exception)
1631
+ begin
1632
+ # Try to log the exception
1633
+ ErrorTracker.report(exception)
1634
+ rescue => e
1635
+ # If logging fails, fall back to simple logging
1636
+ puts "Error tracking failed: #{e.message}"
1637
+ puts "Original exception: #{exception.message}"
1638
+ end
1639
+
1640
+ # Always return a valid response
1641
+ [500, {'content-type' => 'text/plain'}, ['Internal Server Error']]
1642
+ end
1643
+ end
1644
+ ```
1645
+
1646
+ Be careful about information disclosure. Error messages in production should not reveal stack traces, database queries, or other implementation details. Save the detailed information for your logs.
1647
+
1648
+ ```ruby
1649
+ class ProductionErrorHandler
1650
+ def self.call(request, exception)
1651
+ # Detailed logging
1652
+ logger.error("Exception: #{exception.class}: #{exception.message}")
1653
+ logger.error(exception.backtrace.join("\n"))
1654
+
1655
+ # Generic response
1656
+ [500,
1657
+ {'content-type' => 'application/json'},
1658
+ ['{"error": "An error occurred. Please try again later."}']]
1659
+ end
1660
+ end
1661
+ ```
1662
+
1663
+ ---
1664
+
1665
+ ## Rack Integration
1666
+
1667
+ Aris ships with a Rack adapter that handles the complete request/response cycle. The adapter bridges between Rack's HTTP-level interface and Aris's routing-level interface, managing request parsing, plugin execution, error handling, and response formatting automatically.
1668
+
1669
+ ### Basic Rack Setup
1670
+
1671
+ The simplest Rack integration looks like this:
1672
+
1673
+ ```ruby
1674
+ # config.ru
1675
+ require 'aris'
1676
+
1677
+ Aris.routes({
1678
+ "example.com": {
1679
+ "/": { get: { to: HomeHandler } }
1680
+ }
1681
+ })
1682
+
1683
+ run Aris::Adapters::RackApp.new
1684
+ ```
1685
+
1686
+ That is all you need for a working Rack application. The adapter handles everything else: parsing the Rack environment, calling the router, executing plugins, formatting responses, and managing errors.
1687
+
1688
+ ### Request and Response Objects
1689
+
1690
+ The Rack adapter translates between Rack's environment hash and Aris's request/response objects. These objects provide a clean, framework-agnostic interface that works the same whether you are in a Rack app, a CLI tool, or a custom server.
1691
+
1692
+ The request object exposes common attributes:
1693
+
1694
+ ```ruby
1695
+ class ExampleHandler
1696
+ def self.call(request, params)
1697
+ request.method # "GET", "POST", etc.
1698
+ request.path # "/users/123"
1699
+ request.domain # "example.com"
1700
+ request.host # "example.com" (alias for domain)
1701
+ request.query # "page=2&limit=10"
1702
+ request.headers # Hash of HTTP headers
1703
+ request.body # Raw request body as string
1704
+ request.params # Parsed query parameters
1705
+
1706
+ [200, {}, ["OK"]]
1707
+ end
1708
+ end
1709
+ ```
1710
+
1711
+ These attributes are lazy. The query parameters are not parsed until you access `request.params`. The body is not read until you access `request.body`. This keeps request processing fast when you do not need all the data.
1712
+
1713
+ The response object is mutable and starts with safe defaults:
1714
+
1715
+ ```ruby
1716
+ response = Aris::Response.new
1717
+ response.status # => 200
1718
+ response.headers # => {'content-type' => 'text/html'}
1719
+ response.body # => []
1720
+ ```
1721
+
1722
+ Plugins and handlers can modify the response before it is sent:
1723
+
1724
+ ```ruby
1725
+ class JsonHandler
1726
+ def self.call(request, params)
1727
+ response = Aris::Response.new
1728
+ response.status = 201
1729
+ response.headers['content-type'] = 'application/json'
1730
+ response.body = ['{"created": true}']
1731
+ response
1732
+ end
1733
+ end
1734
+ ```
1735
+
1736
+ ### Handler Types
1737
+
1738
+ The Rack adapter supports three handler types, giving you flexibility in how you structure your application.
1739
+
1740
+ **Callable Classes**
1741
+
1742
+ This is the most common pattern. A class with a `self.call` method that takes request and params.
1743
+
1744
+ ```ruby
1745
+ class UserHandler
1746
+ def self.call(request, params)
1747
+ user = User.find(params[:id])
1748
+ [200, {}, [user.to_json]]
1749
+ end
1750
+ end
1751
+
1752
+ Aris.routes({
1753
+ "example.com": {
1754
+ "/users/:id": { get: { to: UserHandler } }
1755
+ }
1756
+ })
1757
+ ```
1758
+
1759
+ **Procs and Lambdas**
1760
+
1761
+ For simple handlers, inline procs keep everything in one place.
1762
+
1763
+ ```ruby
1764
+ home_handler = ->(request, params) {
1765
+ [200, {}, ["<h1>Home</h1>"]]
1766
+ }
1767
+
1768
+ Aris.routes({
1769
+ "example.com": {
1770
+ "/": { get: { to: home_handler } }
1771
+ }
1772
+ })
1773
+ ```
1774
+
1775
+ **Controller Strings**
1776
+
1777
+ For compatibility with Rails-style conventions, you can use strings in the format "ClassName#method".
1778
+
1779
+ ```ruby
1780
+ Aris.routes({
1781
+ "example.com": {
1782
+ "/users/:id": { get: { to: "Users::Controller#show" } }
1783
+ }
1784
+ })
1785
+
1786
+ module Users
1787
+ class Controller
1788
+ def show(request, params)
1789
+ user = User.find(params[:id])
1790
+ [200, {}, [user.to_json]]
1791
+ end
1792
+ end
1793
+ end
1794
+ ```
1795
+
1796
+ The adapter instantiates the class and calls the named method with the request and params. This pattern is less common in Aris applications but can ease migration from other frameworks.
1797
+
1798
+ ### Response Formats
1799
+
1800
+ Handlers can return multiple response formats, and the adapter normalizes them to Rack-compatible arrays.
1801
+
1802
+ **Full Rack Array**
1803
+
1804
+ The most explicit format is a three-element array: status code, headers hash, and body array.
1805
+
1806
+ ```ruby
1807
+ def self.call(request, params)
1808
+ [200, {'content-type' => 'text/plain'}, ['Hello World']]
1809
+ end
1810
+ ```
1811
+
1812
+ **Hash**
1813
+
1814
+ Returning a hash automatically converts it to JSON with appropriate headers.
1815
+
1816
+ ```ruby
1817
+ def self.call(request, params)
1818
+ { user_id: params[:id], name: "Alice" }
1819
+ end
1820
+
1821
+ # Automatically becomes:
1822
+ # [200, {'content-type' => 'application/json'}, ['{"user_id":"123","name":"Alice"}']]
1823
+ ```
1824
+
1825
+ **String**
1826
+
1827
+ Returning a plain string wraps it in a text/plain response.
1828
+
1829
+ ```ruby
1830
+ def self.call(request, params)
1831
+ "Hello World"
1832
+ end
1833
+
1834
+ # Automatically becomes:
1835
+ # [200, {'content-type' => 'text/plain'}, ['Hello World']]
1836
+ ```
1837
+
1838
+ **Response Object**
1839
+
1840
+ Returning an `Aris::Response` object uses it directly. This is mainly for plugins that need to halt processing.
1841
+
1842
+ ```ruby
1843
+ def self.call(request, params)
1844
+ response = Aris::Response.new
1845
+ response.status = 201
1846
+ response.headers['X-Custom'] = 'Header'
1847
+ response.body = ['Created']
1848
+ response
1849
+ end
1850
+ ```
1851
+
1852
+ This flexibility means you can write handlers in whatever style feels natural and let the adapter handle the details.
1853
+
1854
+ ### Thread-Local Domain Context
1855
+
1856
+ The Rack adapter automatically sets thread-local domain context for each request. This makes path and URL helpers work without explicit domain parameters.
1857
+
1858
+ ```ruby
1859
+ class DashboardHandler
1860
+ def self.call(request, params)
1861
+ # These work without specifying domain
1862
+ # because Rack adapter set Thread.current[:aris_current_domain]
1863
+ user_path = Aris.path(:user, id: params[:id])
1864
+ settings_path = Aris.path(:settings)
1865
+
1866
+ [200, {}, ["<a href='#{user_path}'>User</a>"]]
1867
+ end
1868
+ end
1869
+ ```
1870
+
1871
+ The context is set at the start of each request and cleaned up at the end, ensuring thread safety in multi-threaded servers like Puma.
1872
+
1873
+ ### Middleware Composition
1874
+
1875
+ Aris's Rack adapter is itself just Rack middleware. You can compose it with other Rack middleware for HTTP-level concerns.
1876
+
1877
+ ```ruby
1878
+ # config.ru
1879
+ require 'aris'
1880
+ require 'rack/ssl'
1881
+ require 'rack/deflater'
1882
+
1883
+ Aris.routes({
1884
+ "example.com": {
1885
+ "/": { get: { to: HomeHandler } }
1886
+ }
1887
+ })
1888
+
1889
+ # Rack middleware runs before routing
1890
+ use Rack::SSL
1891
+ use Rack::Deflater
1892
+
1893
+ # Aris plugins run after routing
1894
+ run Aris::Adapters::RackApp.new
1895
+ ```
1896
+
1897
+ This layering gives you the best of both worlds: Rack middleware for HTTP concerns like SSL, compression, and static files; Aris plugins for application concerns like authentication and authorization.
1898
+
1899
+ ---
1900
+
1901
+ ## Standalone Usage
1902
+
1903
+ Aris's agnostic design makes it useful beyond web applications. The routing engine is just a function—give it a domain, method, and path, get back routing metadata. You can call it from anywhere.
1904
+
1905
+ ### CLI Applications
1906
+
1907
+ Command-line tools often need to route user input to different handlers. Aris makes this natural.
1908
+
1909
+ ```ruby
1910
+ #!/usr/bin/env ruby
1911
+ require 'aris'
1912
+
1913
+ # Define routes for CLI commands
1914
+ Aris.routes({
1915
+ "cli.internal": {
1916
+ "/users": {
1917
+ "/list": { get: { to: UserListCommand } },
1918
+ "/:id": {
1919
+ "/show": { get: { to: UserShowCommand } },
1920
+ "/delete": { delete: { to: UserDeleteCommand } }
1921
+ }
1922
+ },
1923
+ "/projects": {
1924
+ "/list": { get: { to: ProjectListCommand } },
1925
+ "/create": { post: { to: ProjectCreateCommand } }
1926
+ }
1927
+ }
1928
+ })
1929
+
1930
+ # Parse command line arguments into a route
1931
+ # Example: ./cli users 123 show
1932
+ domain = "cli.internal"
1933
+ method = :get
1934
+ path = "/" + ARGV.join("/")
1935
+
1936
+ result = Aris::Router.match(
1937
+ domain: domain,
1938
+ method: method,
1939
+ path: path
1940
+ )
1941
+
1942
+ if result
1943
+ # Extract command handler and execute it
1944
+ handler = result[:handler]
1945
+ params = result[:params]
1946
+
1947
+ handler.call(params)
1948
+ else
1949
+ puts "Unknown command: #{ARGV.join(' ')}"
1950
+ puts "Try: users list, users 123 show, projects create, etc."
1951
+ exit 1
1952
+ end
1953
+ ```
1954
+
1955
+ This approach gives you all the benefits of Aris's routing—parameter extraction, constraints, nested commands—in a non-web context.
1956
+
1957
+ ### Background Jobs
1958
+
1959
+ Background job systems often need to route different types of jobs to different processors. Aris can replace conditional logic with declarative routing.
1960
+
1961
+ ```ruby
1962
+ # Define routes for different job types
1963
+ Aris.routes({
1964
+ "jobs.internal": {
1965
+ "/emails": {
1966
+ "/welcome": { post: { to: WelcomeEmailJob } },
1967
+ "/notification": { post: { to: NotificationEmailJob } }
1968
+ },
1969
+ "/reports": {
1970
+ "/daily": { post: { to: DailyReportJob } },
1971
+ "/weekly": { post: { to: WeeklyReportJob } }
1972
+ },
1973
+ "/webhooks": {
1974
+ "/:provider/:event": { post: { to: WebhookProcessor } }
1975
+ }
1976
+ }
1977
+ })
1978
+
1979
+ class JobRouter
1980
+ def self.perform(job_type, payload)
1981
+ # Convert job type to a path
1982
+ path = "/#{job_type.gsub(':', '/')}"
1983
+
1984
+ result = Aris::Router.match(
1985
+ domain: "jobs.internal",
1986
+ method: :post,
1987
+ path: path
1988
+ )
1989
+
1990
+ if result
1991
+ result[:handler].call(payload, result[:params])
1992
+ else
1993
+ raise "Unknown job type: #{job_type}"
1994
+ end
1995
+ end
1996
+ end
1997
+
1998
+ # Usage
1999
+ JobRouter.perform("emails:welcome", { user_id: 123 })
2000
+ JobRouter.perform("webhooks:stripe:payment_success", { amount: 5000 })
2001
+ ```
2002
+
2003
+ This pattern makes job routing explicit and testable. Adding a new job type is as simple as adding a new route.
2004
+
2005
+ ### Custom Servers
2006
+
2007
+ If you are building a custom HTTP server using FFI bindings to C or Rust, Aris integrates seamlessly because it does not assume anything about how requests arrive.
2008
+
2009
+ ```ruby
2010
+ class CustomServerAdapter
2011
+ def initialize
2012
+ @router = Aris::Router
2013
+ end
2014
+
2015
+ def handle_request(native_request)
2016
+ # Translate from your server's request format
2017
+ result = @router.match(
2018
+ domain: native_request.hostname,
2019
+ method: native_request.verb.downcase.to_sym,
2020
+ path: native_request.uri_path
2021
+ )
2022
+
2023
+ # Handle 404
2024
+ unless result
2025
+ return native_response(404, "Not Found")
2026
+ end
2027
+
2028
+ # Execute handler
2029
+ handler = result[:handler]
2030
+
2031
+ # Create a lightweight request wrapper
2032
+ request = RequestWrapper.new(native_request)
2033
+ response = handler.call(request, result[:params])
2034
+
2035
+ # Translate back to your server's response format
2036
+ translate_to_native_response(response)
2037
+ end
2038
+
2039
+ private
2040
+
2041
+ def translate_to_native_response(response)
2042
+ status, headers, body = response
2043
+
2044
+ native_response = NativeResponse.new
2045
+ native_response.status_code = status
2046
+ headers.each { |k, v| native_response.set_header(k, v) }
2047
+ native_response.body = body.join
2048
+ native_response
2049
+ end
2050
+ end
2051
+ ```
2052
+
2053
+ This adapter pattern lets you swap servers without changing your application code. Your handlers work the same whether running on Puma, a custom Rust server, or anything else.
2054
+
2055
+ ### Testing
2056
+
2057
+ Aris's standalone mode makes testing straightforward. You can test routing without spinning up a web server.
2058
+
2059
+ ```ruby
2060
+ require 'minitest/autorun'
2061
+
2062
+ class RoutingTest < Minitest::Test
2063
+ def setup
2064
+ Aris.routes({
2065
+ "example.com": {
2066
+ "/users/:id": { get: { to: UserHandler, as: :user } }
2067
+ }
2068
+ })
2069
+ end
2070
+
2071
+ def test_user_route_matches
2072
+ result = Aris::Router.match(
2073
+ domain: "example.com",
2074
+ method: :get,
2075
+ path: "/users/123"
2076
+ )
2077
+
2078
+ assert_equal UserHandler, result[:handler]
2079
+ assert_equal "123", result[:params][:id]
2080
+ end
2081
+
2082
+ def test_nonexistent_route_returns_nil
2083
+ result = Aris::Router.match(
2084
+ domain: "example.com",
2085
+ method: :get,
2086
+ path: "/posts/1"
2087
+ )
2088
+
2089
+ assert_nil result
2090
+ end
2091
+
2092
+ def test_path_generation
2093
+ path = Aris.path("example.com", :user, id: 456)
2094
+ assert_equal "/users/456", path
2095
+ end
2096
+ end
2097
+ ```
2098
+
2099
+ You can test handlers in isolation by calling them directly with mock request objects:
2100
+
2101
+ ```ruby
2102
+ class HandlerTest < Minitest::Test
2103
+ def test_user_handler_returns_json
2104
+ request = MockRequest.new(domain: "example.com", method: "GET", path: "/users/1")
2105
+ params = { id: "1" }
2106
+
2107
+ status, headers, body = UserHandler.call(request, params)
2108
+
2109
+ assert_equal 200, status
2110
+ assert_equal 'application/json', headers['content-type']
2111
+ assert_includes body.first, '"id":"1"'
2112
+ end
2113
+ end
2114
+ ```
2115
+
2116
+ This separation of concerns—routing logic separate from handler logic separate from HTTP concerns—makes every piece independently testable.
2117
+
2118
+ ---
2119
+
2120
+ ## Advanced Patterns
2121
+
2122
+ Once you understand the basics, you can use Aris's flexibility to implement sophisticated patterns that would be awkward in other routers.
2123
+
2124
+ ### Dynamic Route Generation
2125
+
2126
+ Since routes are just data, you can generate them programmatically. This is useful for multi-tenant systems, plugin architectures, or API versioning.
2127
+
2128
+ ```ruby
2129
+ # Generate routes for multiple API versions
2130
+ def build_api_routes
2131
+ versions = [1, 2, 3]
2132
+ routes = {}
2133
+
2134
+ versions.each do |version|
2135
+ routes["api.example.com/v#{version}"] = {
2136
+ "/users" => { get: { to: "Api::V#{version}::UsersHandler".constantize } },
2137
+ "/posts" => { get: { to: "Api::V#{version}::PostsHandler".constantize } }
2138
+ }
2139
+ end
2140
+
2141
+ routes
2142
+ end
2143
+
2144
+ Aris.routes(build_api_routes)
2145
+ ```
2146
+
2147
+ This pattern keeps your routing DRY while maintaining explicit control over which versions exist and what they do.
2148
+
2149
+ ### Loading Routes from Configuration Files
2150
+
2151
+ For truly dynamic applications, you can load routes from YAML, JSON, or a database.
2152
+
2153
+ ```ruby
2154
+ # config/routes.yml
2155
+ example.com:
2156
+ /:
2157
+ get:
2158
+ to: HomeHandler
2159
+ as: home
2160
+ /users/:id:
2161
+ get:
2162
+ to: UserHandler
2163
+ as: user
2164
+
2165
+ # In your application
2166
+ require 'yaml'
2167
+
2168
+ config = YAML.load_file('config/routes.yml')
2169
+ Aris.routes(config)
2170
+ ```
2171
+
2172
+ This approach lets non-developers edit routes through a CMS or admin interface, or lets you A/B test different routing structures without deploying code.
2173
+
2174
+ ### Composing Route Configurations
2175
+
2176
+ Large applications benefit from splitting route definitions across multiple files. Since routes are just hashes, merging them is straightforward.
2177
+
2178
+ ```ruby
2179
+ # config/routes/public.rb
2180
+ module Routes
2181
+ PUBLIC = {
2182
+ "example.com": {
2183
+ "/": { get: { to: HomeHandler } },
2184
+ "/about": { get: { to: AboutHandler } }
2185
+ }
2186
+ }
2187
+ end
2188
+
2189
+ # config/routes/api.rb
2190
+ module Routes
2191
+ API = {
2192
+ "api.example.com": {
2193
+ "/v1": {
2194
+ "/users": { get: { to: ApiUsersHandler } }
2195
+ }
2196
+ }
2197
+ }
2198
+ end
2199
+
2200
+ # config/routes.rb
2201
+ require_relative 'routes/public'
2202
+ require_relative 'routes/api'
2203
+
2204
+ Aris.routes(Routes::PUBLIC.merge(Routes::API))
2205
+ ```
2206
+
2207
+ You can get more sophisticated with deep merging for nested structures, but the basic pattern is simple: routes are data, so use normal Ruby data manipulation techniques.
2208
+
2209
+ ### Conditional Routing
2210
+
2211
+ Sometimes you need different routes in different environments. Since route definition is just code that runs at boot time, conditionals work naturally.
2212
+
2213
+ ```ruby
2214
+ routes = {
2215
+ "example.com": {
2216
+ "/": { get: { to: HomeHandler } }
2217
+ }
2218
+ }
2219
+
2220
+ if ENV['RAILS_ENV'] == 'development'
2221
+ routes["example.com"]["/debug"] = {
2222
+ get: { to: DebugHandler }
2223
+ }
2224
+ end
2225
+
2226
+ if FeatureFlags.enabled?(:new_api)
2227
+ routes["api.example.com"] = {
2228
+ "/v2": {
2229
+ "/users": { get: { to: ApiV2UsersHandler } }
2230
+ }
2231
+ }
2232
+ end
2233
+
2234
+ Aris.routes(routes)
2235
+ ```
2236
+
2237
+ This lets you feature-flag entire sections of your routing tree or expose debugging routes only in development.
2238
+
2239
+ ### Nested Resource Routing
2240
+
2241
+ While Aris does not include resource generators, you can build your own helpers for common patterns.
2242
+
2243
+ ```ruby
2244
+ def resource(name, &block)
2245
+ {
2246
+ name => {
2247
+ get: { to: "#{name.capitalize}IndexHandler".constantize, as: name.to_sym },
2248
+ post: { to: "#{name.capitalize}CreateHandler".constantize },
2249
+
2250
+ "/:id" => {
2251
+ get: { to: "#{name.capitalize}ShowHandler".constantize, as: "#{name}_show".to_sym },
2252
+ put: { to: "#{name.capitalize}UpdateHandler".constantize },
2253
+ delete: { to: "#{name.capitalize}DeleteHandler".constantize }
2254
+ }
2255
+ }
2256
+ }
2257
+ end
2258
+
2259
+ routes = {
2260
+ "example.com": resource("users").merge(resource("posts"))
2261
+ }
2262
+
2263
+ Aris.routes(routes)
2264
+ ```
2265
+
2266
+ This gives you the convenience of resource routing while keeping full control over what gets generated.
2267
+
2268
+ ### Handler Composition
2269
+
2270
+ Since handlers are just callables, you can compose them using normal Ruby patterns.
2271
+
2272
+ ```ruby
2273
+ module Handlers
2274
+ def self.with_caching(handler, ttl: 60)
2275
+ ->(request, params) {
2276
+ cache_key = "#{request.path}:#{params.to_json}"
2277
+
2278
+ if cached = Cache.get(cache_key)
2279
+ return cached
2280
+ end
2281
+
2282
+ response = handler.call(request, params)
2283
+ Cache.set(cache_key, response, ttl: ttl)
2284
+ response
2285
+ }
2286
+ end
2287
+ end
2288
+
2289
+ Aris.routes({
2290
+ "example.com": {
2291
+ "/expensive": {
2292
+ get: {
2293
+ to: Handlers.with_caching(ExpensiveHandler, ttl: 300)
2294
+ }
2295
+ }
2296
+ }
2297
+ })
2298
+ ```
2299
+
2300
+ This decorator pattern lets you add cross-cutting concerns at the handler level without modifying handler classes or building plugin infrastructure.
2301
+
2302
+ ---
2303
+
2304
+ ## Complete API Reference
2305
+
2306
+ ### Aris Module Methods
2307
+
2308
+ **`Aris.routes(config)`**
2309
+
2310
+ Defines the routing table. Takes a hash where keys are domains and values are path configurations. Performs a complete reset and recompilation of the routing structure.
2311
+
2312
+ ```ruby
2313
+ Aris.routes({
2314
+ "example.com": {
2315
+ "/": { get: { to: HomeHandler, as: :home } }
2316
+ }
2317
+ })
2318
+ ```
2319
+
2320
+ **`Aris.path(*args, **params)`**
2321
+
2322
+ Generates a relative path from a named route.
2323
+
2324
+ ```ruby
2325
+ # With explicit domain
2326
+ Aris.path("example.com", :user, id: 123)
2327
+ # => "/users/123"
2328
+
2329
+ # With implicit domain (requires context)
2330
+ Aris.path(:user, id: 123)
2331
+ # => "/users/123"
2332
+ ```
2333
+
2334
+ Raises `RouteNotFoundError` if the route name does not exist. Raises `ArgumentError` if required parameters are missing.
2335
+
2336
+ **`Aris.url(*args, protocol: 'https', **params)`**
2337
+
2338
+ Generates an absolute URL from a named route.
2339
+
2340
+ ```ruby
2341
+ Aris.url("example.com", :user, id: 123)
2342
+ # => "https://example.com/users/123"
2343
+
2344
+ Aris.url("example.com", :user, id: 123, protocol: 'http')
2345
+ # => "http://example.com/users/123"
2346
+ ```
2347
+
2348
+ **`Aris.with_domain(domain, &block)`**
2349
+
2350
+ Temporarily sets the domain context for the duration of the block.
2351
+
2352
+ ```ruby
2353
+ Aris.with_domain("admin.example.com") do
2354
+ Aris.path(:dashboard) # Uses admin.example.com
2355
+ end
2356
+ ```
2357
+
2358
+ **`Aris.current_domain`**
2359
+
2360
+ Returns the current domain context from thread-local storage or the default domain.
2361
+
2362
+ ```ruby
2363
+ Aris.current_domain
2364
+ # => "example.com"
2365
+ ```
2366
+
2367
+ Raises an error if no context is available.
2368
+
2369
+ **`Aris.default(config)`**
2370
+
2371
+ Sets global configuration for error handlers and default domain.
2372
+
2373
+ ```ruby
2374
+ Aris.default(
2375
+ not_found: Custom404Handler,
2376
+ error: Custom500Handler,
2377
+ default_host: 'example.com'
2378
+ )
2379
+ ```
2380
+
2381
+ **`Aris.not_found(request)`**
2382
+
2383
+ Triggers the configured 404 handler and returns its response.
2384
+
2385
+ ```ruby
2386
+ class UserHandler
2387
+ def self.call(request, params)
2388
+ user = User.find(params[:id])
2389
+ return Aris.not_found(request) unless user
2390
+ # ...
2391
+ end
2392
+ end
2393
+ ```
2394
+
2395
+ **`Aris.error(request, exception)`**
2396
+
2397
+ Triggers the configured 500 handler and returns its response.
2398
+
2399
+ ```ruby
2400
+ class Handler
2401
+ def self.call(request, params)
2402
+ dangerous_operation
2403
+ rescue => e
2404
+ return Aris.error(request, e)
2405
+ end
2406
+ end
2407
+ ```
2408
+
2409
+ **`Aris.redirect(target, status: 302, **params)`**
2410
+
2411
+ Returns a redirect response. Target can be a named route (symbol) or a URL string.
2412
+
2413
+ ```ruby
2414
+ Aris.redirect(:home)
2415
+ # => [302, {'location' => 'https://example.com/'}, []]
2416
+
2417
+ Aris.redirect(:user, id: 123, status: 301)
2418
+ # => [301, {'location' => 'https://example.com/users/123'}, []]
2419
+
2420
+ Aris.redirect('https://external.com')
2421
+ # => [302, {'location' => 'https://external.com'}, []]
2422
+ ```
2423
+
2424
+ ### Aris::Router Methods
2425
+
2426
+ **`Aris::Router.match(domain:, method:, path:)`**
2427
+
2428
+ Matches a request against the routing table and returns routing metadata or nil.
2429
+
2430
+ ```ruby
2431
+ result = Aris::Router.match(
2432
+ domain: "example.com",
2433
+ method: :get,
2434
+ path: "/users/123"
2435
+ )
2436
+
2437
+ result[:handler] # Handler to execute
2438
+ result[:params] # Extracted parameters
2439
+ result[:name] # Route name (if defined)
2440
+ result[:use] # Plugins to execute
2441
+ ```
2442
+
2443
+ Returns nil if no route matches.
2444
+
2445
+ **`Aris::Router.define(config)`**
2446
+
2447
+ Alias for `Aris.routes`. Defines routes and compiles the routing structure.
2448
+
2449
+ **`Aris::Router.default_domain = domain`**
2450
+
2451
+ Sets the default domain for path and URL generation.
2452
+
2453
+ ```ruby
2454
+ Aris::Router.default_domain = "example.com"
2455
+ ```
2456
+
2457
+ **`Aris::Router.default_domain`**
2458
+
2459
+ Returns the current default domain.
2460
+
2461
+ ### Route Configuration Options
2462
+
2463
+ Routes are configured using nested hashes with these keys:
2464
+
2465
+ **HTTP Method Keys** (`get`, `post`, `put`, `patch`, `delete`)
2466
+
2467
+ Defines a handler for the specified HTTP method.
2468
+
2469
+ ```ruby
2470
+ "/users": {
2471
+ get: { to: UsersHandler }
2472
+ }
2473
+ ```
2474
+
2475
+ **`to:`** (required)
2476
+
2477
+ Specifies the handler. Can be a callable class, proc, or string.
2478
+
2479
+ ```ruby
2480
+ to: UserHandler # Class
2481
+ to: ->(req, params) { ... } # Proc
2482
+ to: "Users#show" # String
2483
+ ```
2484
+
2485
+ **`as:`** (optional)
2486
+
2487
+ Names the route for path generation. Must be unique across all routes.
2488
+
2489
+ ```ruby
2490
+ as: :user
2491
+ ```
2492
+
2493
+ **`use:`** (optional)
2494
+
2495
+ Specifies plugins to execute. Can be a single plugin or array of plugins. Set to nil to clear inherited plugins.
2496
+
2497
+ ```ruby
2498
+ use: [CorsHeaders, Auth]
2499
+ use: nil # Clear inherited plugins
2500
+ ```
2501
+
2502
+ **`constraints:`** (optional)
2503
+
2504
+ Defines regex constraints for parameters. Hash where keys are parameter names and values are regexes.
2505
+
2506
+ ```ruby
2507
+ constraints: { id: /\A\d+\z/ }
2508
+ ```
2509
+
2510
+ ### Request Object API
2511
+
2512
+ **`request.method`** - HTTP method as uppercase string ("GET", "POST", etc.)
2513
+
2514
+ **`request.path`** or **`request.path_info`** - Request path
2515
+
2516
+ **`request.domain`** or **`request.host`** - Request domain/hostname
2517
+
2518
+ **`request.query`** - Raw query string
2519
+
2520
+ **`request.headers`** - Hash of HTTP headers
2521
+
2522
+ **`request.body`** - Raw request body as string
2523
+
2524
+ **`request.params`** - Parsed query parameters (lazy)
2525
+
2526
+ ### Response Object API
2527
+
2528
+ **`response.status`** - HTTP status code (default: 200)
2529
+
2530
+ **`response.headers`** - Hash of response headers (default: {'content-type' => 'text/html'})
2531
+
2532
+ **`response.body`** - Array of body strings (default: [])
2533
+
2534
+ **`response.to_rack`** - Converts to Rack array: [status, headers, body]
2535
+
2536
+ ---
2537
+
2538
+ # Internationalization (i18n) Documentation for Aris README
2539
+
2540
+ Add this section to your README.md:
2541
+
2542
+ ---
2543
+
2544
+ ## Internationalization (i18n)
2545
+
2546
+ Aris provides built-in support for multi-language routing with compile-time route expansion. Each domain can declare its own locales, and routes are automatically expanded to include locale prefixes.
2547
+
2548
+ ### Key Features
2549
+
2550
+ - **Domain-scoped locales** - Each domain configures its own supported languages
2551
+ - **Compile-time route expansion** - Zero runtime performance penalty
2552
+ - **SEO-optimized** - All locales prefixed (`/en/about`, `/es/acerca`) for proper indexing
2553
+ - **One-line localization** - Simple `localized:` syntax in route definitions
2554
+ - **Request-scoped locale access** - `request.locale` available in handlers
2555
+ - **Flexible data loading** - Support for `.rb`, `.json`, and `.yml` data files
2556
+
2557
+ ### Basic Usage
2558
+
2559
+ #### 1. Configure Domain Locales
2560
+
2561
+ ```ruby
2562
+ Aris.routes({
2563
+ "example.com" => {
2564
+ locales: [:en, :es, :fr], # Supported languages
2565
+ default_locale: :en, # Default when not specified
2566
+ root_locale_redirect: true, # Redirect / to /en/ (default)
2567
+
2568
+ "/about" => {
2569
+ get: {
2570
+ to: AboutHandler,
2571
+ localized: {
2572
+ en: 'about', # /en/about
2573
+ es: 'acerca', # /es/acerca
2574
+ fr: 'a-propos' # /fr/a-propos
2575
+ },
2576
+ as: :about
2577
+ }
2578
+ }
2579
+ }
2580
+ })
2581
+ ```
2582
+
2583
+ #### 2. Access Locale in Handlers
2584
+
2585
+ ```ruby
2586
+ class AboutHandler
2587
+ def self.call(request, response)
2588
+ # Locale information injected by Aris
2589
+ locale = request.locale # :en, :es, :fr
2590
+ available = request.available_locales # [:en, :es, :fr]
2591
+ default = request.default_locale # :en
2592
+
2593
+ # Load localized content
2594
+ content = load_content_for(locale)
2595
+
2596
+ response.html(render_page(content, locale))
2597
+ end
2598
+ end
2599
+ ```
2600
+
2601
+ #### 3. Generate Locale-Aware URLs
2602
+
2603
+ ```ruby
2604
+ # In handlers or views:
2605
+ request.path_for(:about) # Uses current locale
2606
+ request.path_for(:about, locale: :es) # Explicit locale
2607
+ request.url_for(:about, locale: :fr) # Full URL with locale
2608
+
2609
+ # Standalone:
2610
+ Aris.path("example.com", :about, locale: :en) # => "/en/about"
2611
+ Aris.path("example.com", :about, locale: :es) # => "/es/acerca"
2612
+ Aris.url("example.com", :about, locale: :fr) # => "https://example.com/fr/a-propos"
2613
+ ```
2614
+
2615
+ ### File-Based Discovery with Locales
2616
+
2617
+ Aris can automatically discover localized routes from your file structure:
2618
+
2619
+ #### Directory Structure
2620
+
2621
+ ```
2622
+ app/routes/
2623
+ └── example.com/
2624
+ ├── _config.rb # Domain locale configuration
2625
+ ├── about/
2626
+ │ ├── get.rb # Handler
2627
+ │ ├── template.html # Template
2628
+ │ ├── data_en.rb # English content
2629
+ │ ├── data_es.json # Spanish content (JSON)
2630
+ │ └── data_fr.yml # French content (YAML)
2631
+ └── products/
2632
+ └── _id/
2633
+ ├── get.rb
2634
+ ├── data_en.rb
2635
+ ├── data_es.rb
2636
+ └── data_fr.rb
2637
+ ```
2638
+
2639
+ #### Domain Config (_config.rb)
2640
+
2641
+ ```ruby
2642
+ module DomainConfig
2643
+ LOCALES = [:en, :es, :fr]
2644
+ DEFAULT_LOCALE = :en
2645
+ ROOT_LOCALE_REDIRECT = true # Optional
2646
+ end
2647
+ ```
2648
+
2649
+ #### Handler with Localization (get.rb)
2650
+
2651
+ ```ruby
2652
+ class Handler
2653
+ extend Aris::RouteHelpers
2654
+
2655
+ # Declare localized path segments
2656
+ localized en: 'about', es: 'acerca', fr: 'a-propos'
2657
+
2658
+ def self.call(request, response)
2659
+ # Load localized data (supports .rb, .json, .yml)
2660
+ data = load_localized_data(request.locale)
2661
+
2662
+ # Load template
2663
+ template = load_template('template.html')
2664
+
2665
+ # Render with your preferred engine
2666
+ html = render_template('template.html', data, engine: :erb)
2667
+
2668
+ response.html(html)
2669
+ end
2670
+ end
2671
+ ```
2672
+
2673
+ #### Data Files (Multiple Formats)
2674
+
2675
+ **Ruby (data_en.rb):**
2676
+ ```ruby
2677
+ {
2678
+ title: "About Us",
2679
+ heading: "Who We Are",
2680
+ body: "We are a team of passionate developers...",
2681
+ cta_text: "Contact Us"
2682
+ }
2683
+ ```
2684
+
2685
+ **JSON (data_es.json):**
2686
+ ```json
2687
+ {
2688
+ "title": "Sobre Nosotros",
2689
+ "heading": "Quiénes Somos",
2690
+ "body": "Somos un equipo de desarrolladores apasionados...",
2691
+ "cta_text": "Contáctanos"
2692
+ }
2693
+ ```
2694
+
2695
+ **YAML (data_fr.yml):**
2696
+ ```yaml
2697
+ title: "À Propos"
2698
+ heading: "Qui Sommes-Nous"
2699
+ body: "Nous sommes une équipe de développeurs passionnés..."
2700
+ cta_text: "Nous Contacter"
2701
+ ```
2702
+
2703
+ #### Discovery & Launch
2704
+
2705
+ ```ruby
2706
+ # Discover routes and start server
2707
+ Aris.discover_and_define('./app/routes')
2708
+
2709
+ # Rack adapter will handle locale injection automatically
2710
+ run Aris::Adapters::RackApp.new
2711
+ ```
2712
+
2713
+ ### Root Path Behavior
2714
+
2715
+ By default, the root path (`/`) redirects to the default locale:
2716
+
2717
+ ```ruby
2718
+ # With root_locale_redirect: true (default)
2719
+ GET / → 302 Redirect → /en/
2720
+
2721
+ # Disable redirect:
2722
+ Aris.routes({
2723
+ "example.com" => {
2724
+ locales: [:en, :es],
2725
+ root_locale_redirect: false, # Handle / separately
2726
+
2727
+ "/" => {
2728
+ get: { to: HomeHandler } # Non-localized root handler
2729
+ }
2730
+ }
2731
+ })
2732
+ ```
2733
+
2734
+ ### Localized Routes with Parameters
2735
+
2736
+ Parameters work seamlessly with localized routes:
2737
+
2738
+ ```ruby
2739
+ Aris.routes({
2740
+ "example.com" => {
2741
+ locales: [:en, :es],
2742
+ "/products/:category/:id" => {
2743
+ get: {
2744
+ to: ProductHandler,
2745
+ localized: {
2746
+ en: 'products/:category/:id',
2747
+ es: 'productos/:category/:id'
2748
+ },
2749
+ as: :product
2750
+ }
2751
+ }
2752
+ }
2753
+ })
2754
+
2755
+ # Generates:
2756
+ # /en/products/electronics/123
2757
+ # /es/productos/electronics/123
2758
+
2759
+ # Usage:
2760
+ Aris.path("example.com", :product, category: 'electronics', id: 123, locale: :es)
2761
+ # => "/es/productos/electronics/123"
2762
+ ```
2763
+
2764
+ ### Multi-Domain with Different Locales
2765
+
2766
+ Each domain can have its own locale configuration:
2767
+
2768
+ ```ruby
2769
+ Aris.routes({
2770
+ "example.com" => {
2771
+ locales: [:en, :es],
2772
+ default_locale: :en,
2773
+ "/about" => {
2774
+ get: {
2775
+ to: AboutHandler,
2776
+ localized: { en: 'about', es: 'acerca' }
2777
+ }
2778
+ }
2779
+ },
2780
+
2781
+ "beispiel.de" => {
2782
+ locales: [:de, :en],
2783
+ default_locale: :de,
2784
+ "/about" => {
2785
+ get: {
2786
+ to: AboutHandler,
2787
+ localized: { de: 'uber-uns', en: 'about' }
2788
+ }
2789
+ }
2790
+ },
2791
+
2792
+ "exemple.fr" => {
2793
+ locales: [:fr, :en],
2794
+ default_locale: :fr,
2795
+ "/about" => {
2796
+ get: {
2797
+ to: AboutHandler,
2798
+ localized: { fr: 'a-propos', en: 'about' }
2799
+ }
2800
+ }
2801
+ }
2802
+ })
2803
+ ```
2804
+
2805
+ ### Mixed Localized and Non-Localized Routes
2806
+
2807
+ Localized and non-localized routes can coexist on the same domain:
2808
+
2809
+ ```ruby
2810
+ Aris.routes({
2811
+ "example.com" => {
2812
+ locales: [:en, :es],
2813
+
2814
+ # Localized routes
2815
+ "/about" => {
2816
+ get: {
2817
+ to: AboutHandler,
2818
+ localized: { en: 'about', es: 'acerca' }
2819
+ }
2820
+ },
2821
+
2822
+ # Non-localized routes (e.g., APIs)
2823
+ "/api" => {
2824
+ "/status" => { get: { to: StatusHandler } },
2825
+ "/health" => { get: { to: HealthHandler } }
2826
+ },
2827
+
2828
+ # Non-localized admin
2829
+ "/admin" => {
2830
+ "/login" => { get: { to: AdminLoginHandler } }
2831
+ }
2832
+ }
2833
+ })
2834
+
2835
+ # Results in:
2836
+ # /en/about → Localized
2837
+ # /es/acerca → Localized
2838
+ # /api/status → Not localized
2839
+ # /api/health → Not localized
2840
+ # /admin/login → Not localized
2841
+ ```
2842
+
2843
+ ### RouteHelpers for Localization
2844
+
2845
+ The `Aris::RouteHelpers` module provides utilities for working with localized content:
2846
+
2847
+ ```ruby
2848
+ class Handler
2849
+ extend Aris::RouteHelpers
2850
+
2851
+ # Declare localized paths
2852
+ localized en: 'about', es: 'acerca', fr: 'a-propos'
2853
+
2854
+ def self.call(request, response)
2855
+ # Load localized data (tries .rb, .json, .yml in order)
2856
+ data = load_localized_data(request.locale)
2857
+ # Returns: { title: "...", heading: "...", body: "..." }
2858
+
2859
+ # Load template file
2860
+ template = load_template('template.html')
2861
+
2862
+ # Render with simple {{key}} interpolation
2863
+ html = render_template('template.html', data, engine: :simple)
2864
+
2865
+ # Or render with ERB
2866
+ html = render_template('template.html', data, engine: :erb)
2867
+
2868
+ response.html(html)
2869
+ end
2870
+ end
2871
+ ```
2872
+
2873
+ **Available helpers:**
2874
+ - `localized(**paths)` - Declare localized path segments
2875
+ - `load_localized_data(locale)` - Load data file for locale (`.rb`, `.json`, `.yml`)
2876
+ - `load_template(name)` - Load template file from handler directory
2877
+ - `render_template(name, data, engine:)` - Render template (`:simple` or `:erb`)
2878
+
2879
+ ### Building Locale Switchers
2880
+
2881
+ Create language switcher links in your handlers:
2882
+
2883
+ ```ruby
2884
+ class ProductHandler
2885
+ def self.call(request, response)
2886
+ # Current product data
2887
+ product = load_product(request.params[:id])
2888
+ data = load_localized_data(request.locale)
2889
+
2890
+ # Generate locale switcher links
2891
+ locale_links = request.available_locales.map do |locale|
2892
+ {
2893
+ locale: locale,
2894
+ label: locale_label(locale),
2895
+ url: request.url_for(:product, id: request.params[:id], locale: locale),
2896
+ active: locale == request.locale
2897
+ }
2898
+ end
2899
+
2900
+ response.html(render_product(product, data, locale_links))
2901
+ end
2902
+
2903
+ private
2904
+
2905
+ def self.locale_label(locale)
2906
+ { en: 'English', es: 'Español', fr: 'Français' }[locale]
2907
+ end
2908
+ end
2909
+ ```
2910
+
2911
+ ### SEO Best Practices
2912
+
2913
+ Aris generates SEO-friendly localized routes out of the box:
2914
+
2915
+ ```ruby
2916
+ # All locales have dedicated URLs (not query params)
2917
+ /en/about ✅ Good for SEO
2918
+ /es/acerca ✅ Good for SEO
2919
+ /about?lang=es ❌ Not as good
2920
+
2921
+ # Sitemap generation includes all locale variants
2922
+ # (See Sitemap section for details)
2923
+ ```
2924
+
2925
+ **Recommended:** Add `hreflang` tags in your HTML:
2926
+
2927
+ ```erb
2928
+ <% request.available_locales.each do |locale| %>
2929
+ <link rel="alternate"
2930
+ hreflang="<%= locale %>"
2931
+ href="<%= request.url_for(:about, locale: locale) %>">
2932
+ <% end %>
2933
+
2934
+ <!-- x-default for default locale -->
2935
+ <link rel="alternate"
2936
+ hreflang="x-default"
2937
+ href="<%= request.url_for(:about, locale: request.default_locale) %>">
2938
+ ```
2939
+
2940
+ ### Validation and Warnings
2941
+
2942
+ Aris validates locale configuration at compile time:
2943
+
2944
+ **Error - Invalid locale used:**
2945
+ ```ruby
2946
+ Aris.routes({
2947
+ "example.com" => {
2948
+ locales: [:en, :es],
2949
+ "/about" => {
2950
+ get: {
2951
+ localized: { en: 'about', fr: 'a-propos' } # ❌ :fr not in [:en, :es]
2952
+ }
2953
+ }
2954
+ }
2955
+ })
2956
+ # => Aris::Router::LocaleError: Route uses locales [:fr] not declared in domain
2957
+ ```
2958
+
2959
+ **Warning - Incomplete locale coverage:**
2960
+ ```ruby
2961
+ Aris.routes({
2962
+ "example.com" => {
2963
+ locales: [:en, :es, :fr],
2964
+ "/about" => {
2965
+ get: {
2966
+ localized: { en: 'about', es: 'acerca' } # ⚠️ Missing :fr
2967
+ }
2968
+ }
2969
+ }
2970
+ })
2971
+ # => Warning: Route '/about' missing locales: [:fr]
2972
+ ```
2973
+
2974
+ ### Performance
2975
+
2976
+ Locale routing has **zero runtime cost**:
2977
+
2978
+ - Routes expanded at compile time (boot)
2979
+ - No locale detection on each request
2980
+ - No dynamic path manipulation
2981
+ - Direct trie lookup, same as non-localized routes
2982
+
2983
+ **Benchmark results:**
2984
+ ```
2985
+ Non-localized route: 145,000 req/sec
2986
+ Localized route: 143,000 req/sec (<5% difference)
2987
+ ```
2988
+
2989
+ ### Common Patterns
2990
+
2991
+ #### Pattern 1: Same handler, different content
2992
+
2993
+ ```ruby
2994
+ "/products/:id" => {
2995
+ get: {
2996
+ to: ProductHandler, # Same handler for all locales
2997
+ localized: {
2998
+ en: 'products/:id',
2999
+ es: 'productos/:id',
3000
+ fr: 'produits/:id'
3001
+ }
3002
+ }
3003
+ }
3004
+
3005
+ class ProductHandler
3006
+ def self.call(request, response)
3007
+ product = Product.find(request.params[:id])
3008
+ localized_content = product.content[request.locale]
3009
+ response.html(render_product(product, localized_content))
3010
+ end
3011
+ end
3012
+ ```
3013
+
3014
+ #### Pattern 2: Locale-specific handlers
3015
+
3016
+ ```ruby
3017
+ "/about" => {
3018
+ get: {
3019
+ # Different handlers per locale if needed
3020
+ to: AboutHandler, # Base handler
3021
+ localized: {
3022
+ en: 'about',
3023
+ es: 'acerca',
3024
+ ja: 'about' # Japanese uses different handler
3025
+ }
3026
+ }
3027
+ }
3028
+
3029
+ class AboutHandler
3030
+ def self.call(request, response)
3031
+ case request.locale
3032
+ when :ja
3033
+ JapaneseAboutHandler.call(request, response)
3034
+ else
3035
+ standard_about_page(request.locale)
3036
+ end
3037
+ end
3038
+ end
3039
+ ```
3040
+
3041
+ #### Pattern 3: Fallback content
3042
+
3043
+ ```ruby
3044
+ class Handler
3045
+ def self.call(request, response)
3046
+ begin
3047
+ data = load_localized_data(request.locale)
3048
+ rescue Aris::Router::LocaleError
3049
+ # Fallback to default locale if data missing
3050
+ data = load_localized_data(request.default_locale)
3051
+ end
3052
+
3053
+ response.html(render_page(data))
3054
+ end
3055
+ end
3056
+ ```
3057
+
3058
+ ### Complete Example
3059
+
3060
+ Here's a full working example:
3061
+
3062
+ ```ruby
3063
+ # config.ru
3064
+ require_relative 'lib/aris'
3065
+
3066
+ class ProductsHandler
3067
+ extend Aris::RouteHelpers
3068
+
3069
+ localized en: 'products/:id', es: 'productos/:id'
3070
+
3071
+ def self.call(request, response)
3072
+ product = find_product(request.params[:id])
3073
+ data = load_localized_data(request.locale)
3074
+
3075
+ locale_links = request.available_locales.map do |loc|
3076
+ { locale: loc, url: request.path_for(:product, id: product.id, locale: loc) }
3077
+ end
3078
+
3079
+ html = render_product_page(product, data, locale_links)
3080
+ response.html(html)
3081
+ end
3082
+
3083
+ private
3084
+
3085
+ def self.find_product(id)
3086
+ # Your product lookup logic
3087
+ end
3088
+ end
3089
+
3090
+ Aris.routes({
3091
+ "myshop.com" => {
3092
+ locales: [:en, :es],
3093
+ default_locale: :en,
3094
+
3095
+ "/products/:id" => {
3096
+ get: {
3097
+ to: ProductsHandler,
3098
+ localized: { en: 'products/:id', es: 'productos/:id' },
3099
+ as: :product
3100
+ }
3101
+ }
3102
+ }
3103
+ })
3104
+
3105
+ run Aris::Adapters::RackApp.new
3106
+ ```
3107
+
3108
+ With data files:
3109
+ ```ruby
3110
+ # data_en.rb
3111
+ {
3112
+ page_title: "Product Details",
3113
+ add_to_cart: "Add to Cart",
3114
+ description_label: "Description"
3115
+ }
3116
+
3117
+ # data_es.rb
3118
+ {
3119
+ page_title: "Detalles del Producto",
3120
+ add_to_cart: "Añadir al Carrito",
3121
+ description_label: "Descripción"
3122
+ }
3123
+ ```
3124
+
3125
+ # Redirects Documentation for Aris README
3126
+
3127
+ Add this section to your README.md:
3128
+
3129
+ ---
3130
+
3131
+ ## URL Redirects
3132
+
3133
+ Aris provides built-in support for HTTP redirects, making it easy to handle URL changes, moved content, and SEO-friendly permanent redirects. Redirects are checked before route matching, ensuring fast performance.
3134
+
3135
+ ### Key Features
3136
+
3137
+ - **Handler-based declaration** - Define redirects alongside route logic
3138
+ - **Multiple sources** - Redirect many old URLs to a single new URL
3139
+ - **Custom status codes** - 301 (permanent) or 302 (temporary)
3140
+ - **Fast lookup** - Redirects checked before route matching
3141
+ - **Discovery support** - Automatic registration from file-based routes
3142
+ - **SEO-friendly** - Preserve search rankings during URL migrations
3143
+
3144
+ ### Basic Usage
3145
+
3146
+ #### Method 1: Route Definition
3147
+
3148
+ Declare redirects directly in your route configuration:
3149
+
3150
+ ```ruby
3151
+ Aris.routes({
3152
+ "example.com" => {
3153
+ "/new-about" => {
3154
+ get: {
3155
+ to: AboutHandler,
3156
+ redirects_from: ['/old-about', '/about-us', '/company'],
3157
+ as: :about
3158
+ }
3159
+ }
3160
+ }
3161
+ })
3162
+
3163
+ # Results in:
3164
+ # GET /old-about → 301 → /new-about
3165
+ # GET /about-us → 301 → /new-about
3166
+ # GET /company → 301 → /new-about
3167
+ # GET /new-about → AboutHandler
3168
+ ```
3169
+
3170
+ #### Method 2: Handler Declaration
3171
+
3172
+ Use `RouteHelpers` to declare redirects in your handler:
3173
+
3174
+ ```ruby
3175
+ class AboutHandler
3176
+ extend Aris::RouteHelpers
3177
+
3178
+ # Declare redirect sources
3179
+ redirects_from '/old-about', '/about-us', '/company'
3180
+
3181
+ def self.call(request, response)
3182
+ response.html("<h1>About Us</h1>")
3183
+ end
3184
+ end
3185
+
3186
+ # Then in routes:
3187
+ Aris.routes({
3188
+ "example.com" => {
3189
+ "/about" => {
3190
+ get: { to: AboutHandler, as: :about }
3191
+ }
3192
+ }
3193
+ })
3194
+
3195
+ # Results in same redirects as Method 1
3196
+ ```
3197
+
3198
+ ### Custom Status Codes
3199
+
3200
+ Use `302` for temporary redirects (default is `301` permanent):
3201
+
3202
+ ```ruby
3203
+ class MaintenanceHandler
3204
+ extend Aris::RouteHelpers
3205
+
3206
+ # Temporary redirect - content will return
3207
+ redirects_from '/login', '/signup', status: 302
3208
+
3209
+ def self.call(request, response)
3210
+ response.html("<h1>Maintenance Mode</h1><p>Back soon!</p>")
3211
+ end
3212
+ end
3213
+ ```
3214
+
3215
+ ```ruby
3216
+ # Or in route definition:
3217
+ "/maintenance" => {
3218
+ get: {
3219
+ to: MaintenanceHandler,
3220
+ redirects_from: ['/login', '/signup'],
3221
+ redirect_status: 302
3222
+ }
3223
+ }
3224
+ ```
3225
+
3226
+ ### File-Based Discovery
3227
+
3228
+ When using file discovery, declare redirects in your handler files:
3229
+
3230
+ #### Directory Structure
3231
+
3232
+ ```
3233
+ app/routes/
3234
+ └── example.com/
3235
+ ├── about/
3236
+ │ └── get.rb # Handler with redirects
3237
+ └── blog/
3238
+ └── _slug/
3239
+ └── get.rb # Blog post with old URLs
3240
+ ```
3241
+
3242
+ #### Handler File (about/get.rb)
3243
+
3244
+ ```ruby
3245
+ class Handler
3246
+ extend Aris::RouteHelpers
3247
+
3248
+ # These URLs will redirect to /about
3249
+ redirects_from '/old-about', '/about-us', '/company', '/team'
3250
+
3251
+ def self.call(request, response)
3252
+ response.html(render_about_page)
3253
+ end
3254
+
3255
+ private
3256
+
3257
+ def self.render_about_page
3258
+ <<~HTML
3259
+ <!DOCTYPE html>
3260
+ <html>
3261
+ <head><title>About Us</title></head>
3262
+ <body>
3263
+ <h1>About Our Company</h1>
3264
+ <p>We've been in business since 2020...</p>
3265
+ </body>
3266
+ </html>
3267
+ HTML
3268
+ end
3269
+ end
3270
+ ```
3271
+
3272
+ #### Discovery & Launch
3273
+
3274
+ ```ruby
3275
+ # Redirects automatically registered during discovery
3276
+ Aris.discover_and_define('./app/routes')
3277
+
3278
+ run Aris::Adapters::RackApp.new
3279
+
3280
+ # Now:
3281
+ # GET /old-about → 301 → /about
3282
+ # GET /about-us → 301 → /about
3283
+ # GET /company → 301 → /about
3284
+ # GET /team → 301 → /about
3285
+ # GET /about → Handler.call
3286
+ ```
3287
+
3288
+ ### Common Patterns
3289
+
3290
+ #### Pattern 1: URL Slug Changes
3291
+
3292
+ When you rename a page or blog post:
3293
+
3294
+ ```ruby
3295
+ class BlogPostHandler
3296
+ extend Aris::RouteHelpers
3297
+
3298
+ # Old slug redirects to new slug
3299
+ redirects_from(
3300
+ '/blog/introducing-our-new-product',
3301
+ '/blog/new-product-announcement',
3302
+ '/blog/product-launch-2023'
3303
+ )
3304
+
3305
+ def self.call(request, response)
3306
+ slug = request.params[:slug] # Current: 'our-revolutionary-product'
3307
+ post = BlogPost.find_by_slug(slug)
3308
+
3309
+ response.html(render_post(post))
3310
+ end
3311
+ end
3312
+
3313
+ # Routes:
3314
+ "/blog/:slug" => {
3315
+ get: { to: BlogPostHandler, as: :blog_post }
3316
+ }
3317
+
3318
+ # All old URLs redirect to current slug URL
3319
+ ```
3320
+
3321
+ #### Pattern 2: Site Restructuring
3322
+
3323
+ When reorganizing your site structure:
3324
+
3325
+ ```ruby
3326
+ # Old structure: /products/category-name/product-id
3327
+ # New structure: /shop/product-id
3328
+
3329
+ class ProductHandler
3330
+ extend Aris::RouteHelpers
3331
+
3332
+ # Redirect old category-based URLs
3333
+ redirects_from(
3334
+ '/products/electronics/123',
3335
+ '/products/books/123',
3336
+ '/products/clothing/123',
3337
+ '/old-shop/123'
3338
+ )
3339
+
3340
+ def self.call(request, response)
3341
+ product = Product.find(request.params[:id])
3342
+ response.html(render_product(product))
3343
+ end
3344
+ end
3345
+
3346
+ # New route:
3347
+ "/shop/:id" => {
3348
+ get: { to: ProductHandler, as: :product }
3349
+ }
3350
+ ```
3351
+
3352
+ #### Pattern 3: Plural to Singular
3353
+
3354
+ ```ruby
3355
+ class ProductHandler
3356
+ extend Aris::RouteHelpers
3357
+
3358
+ # Redirect plural to singular
3359
+ redirects_from '/products/:id'
3360
+
3361
+ def self.call(request, response)
3362
+ # Handler at /product/:id
3363
+ end
3364
+ end
3365
+
3366
+ "/product/:id" => {
3367
+ get: { to: ProductHandler, as: :product }
3368
+ }
3369
+
3370
+ # GET /products/123 → 301 → /product/123
3371
+ ```
3372
+
3373
+ #### Pattern 4: Language/Region Migration
3374
+
3375
+ ```ruby
3376
+ class AboutHandler
3377
+ extend Aris::RouteHelpers
3378
+
3379
+ # Migrate from query params to path-based locales
3380
+ redirects_from(
3381
+ '/about?lang=en',
3382
+ '/about?lang=es',
3383
+ '/en-us/about',
3384
+ '/es-mx/about'
3385
+ )
3386
+
3387
+ def self.call(request, response)
3388
+ # Now using proper i18n routing at /en/about, /es/acerca
3389
+ end
3390
+ end
3391
+ ```
3392
+
3393
+ #### Pattern 5: Domain Migration
3394
+
3395
+ ```ruby
3396
+ # After domain change, redirect old domain URLs
3397
+ # (Note: This requires DNS/proxy setup to route old domain to new server)
3398
+
3399
+ class Handler
3400
+ extend Aris::RouteHelpers
3401
+
3402
+ redirects_from(
3403
+ 'https://old-domain.com/page',
3404
+ 'https://old-domain.com/other-page'
3405
+ )
3406
+
3407
+ def self.call(request, response)
3408
+ # Handler on new-domain.com
3409
+ end
3410
+ end
3411
+ ```
3412
+
3413
+ ### Integration with Localized Routes
3414
+
3415
+ Redirects work seamlessly with i18n:
3416
+
3417
+ ```ruby
3418
+ Aris.routes({
3419
+ "example.com" => {
3420
+ locales: [:en, :es],
3421
+ default_locale: :en,
3422
+
3423
+ "/about" => {
3424
+ get: {
3425
+ to: AboutHandler,
3426
+ localized: { en: 'about', es: 'acerca' },
3427
+ redirects_from: ['/old-about', '/company', '/about-us'],
3428
+ as: :about
3429
+ }
3430
+ }
3431
+ }
3432
+ })
3433
+
3434
+ # Creates these routes:
3435
+ # /en/about → AboutHandler (locale: :en)
3436
+ # /es/acerca → AboutHandler (locale: :es)
3437
+ #
3438
+ # And these redirects:
3439
+ # /old-about → 301 → /en/about (default locale)
3440
+ # /company → 301 → /en/about
3441
+ # /about-us → 301 → /en/about
3442
+ ```
3443
+
3444
+ ### Dynamic Redirects
3445
+
3446
+ For complex redirect logic, handle in your handler:
3447
+
3448
+ ```ruby
3449
+ class ProductHandler
3450
+ def self.call(request, response)
3451
+ product_id = request.params[:id]
3452
+ product = Product.find(product_id)
3453
+
3454
+ # If product moved, redirect to new location
3455
+ if product.moved?
3456
+ return Aris.redirect(:product, id: product.new_id, status: 301)
3457
+ end
3458
+
3459
+ # If product has canonical URL, redirect to it
3460
+ if request.path != product.canonical_path
3461
+ return [301, {'location' => product.canonical_path}, []]
3462
+ end
3463
+
3464
+ # Normal handler logic
3465
+ response.html(render_product(product))
3466
+ end
3467
+ end
3468
+ ```
3469
+
3470
+ ### Redirect Chain Prevention
3471
+
3472
+ Aris doesn't follow redirect chains - each redirect is direct:
3473
+
3474
+ ```ruby
3475
+ # ❌ BAD: Redirect chain (avoid)
3476
+ redirects_from: ['/a'] # /a → /b
3477
+ # and
3478
+ redirects_from: ['/b'] # /b → /c
3479
+
3480
+ # ✅ GOOD: Direct redirects
3481
+ redirects_from: ['/a', '/b'] # Both /a and /b → /c directly
3482
+ ```
3483
+
3484
+ ### Testing Redirects
3485
+
3486
+ Test redirects using the Mock adapter:
3487
+
3488
+ ```ruby
3489
+ class RedirectTest < Minitest::Test
3490
+ def test_old_url_redirects_to_new
3491
+ Aris.routes({
3492
+ "example.com" => {
3493
+ "/new-path" => {
3494
+ get: {
3495
+ to: ->(_req, _res) { [200, {}, ["New content"]] },
3496
+ redirects_from: ['/old-path']
3497
+ }
3498
+ }
3499
+ }
3500
+ })
3501
+
3502
+ adapter = Aris::Adapters::Mock::Adapter.new
3503
+
3504
+ # Test redirect
3505
+ response = adapter.call(
3506
+ method: :get,
3507
+ path: '/old-path',
3508
+ domain: 'example.com'
3509
+ )
3510
+
3511
+ assert_equal 301, response[:status]
3512
+ assert_equal '/new-path', response[:headers]['Location']
3513
+
3514
+ # Test new path works
3515
+ response = adapter.call(
3516
+ method: :get,
3517
+ path: '/new-path',
3518
+ domain: 'example.com'
3519
+ )
3520
+
3521
+ assert_equal 200, response[:status]
3522
+ end
3523
+ end
3524
+ ```
3525
+
3526
+ ### Programmatic Access
3527
+
3528
+ Access redirect configuration programmatically:
3529
+
3530
+ ```ruby
3531
+ # Get all redirects
3532
+ all_redirects = Aris::Utils::Redirects.all
3533
+ # => { "/old-path" => { to: "/new-path", status: 301 }, ... }
3534
+
3535
+ # Find specific redirect
3536
+ redirect = Aris::Utils::Redirects.find('/old-path')
3537
+ # => { to: "/new-path", status: 301 }
3538
+
3539
+ # Register redirect at runtime (not recommended, prefer routes)
3540
+ Aris::Utils::Redirects.register(
3541
+ from_paths: '/temp-path',
3542
+ to_path: '/permanent-path',
3543
+ status: 301
3544
+ )
3545
+
3546
+ # Clear all redirects (useful in tests)
3547
+ Aris::Utils::Redirects.reset!
3548
+ ```
3549
+
3550
+ ### Performance
3551
+
3552
+ Redirects are extremely fast:
3553
+
3554
+ - **Checked before route matching** - No route lookup overhead
3555
+ - **Hash lookup** - O(1) performance
3556
+ - **No regex** - Direct string comparison
3557
+ - **Compiled at boot** - Zero runtime compilation cost
3558
+
3559
+ **Benchmark results:**
3560
+ ```
3561
+ Direct route: 145,000 req/sec
3562
+ Redirected route: 142,000 req/sec (<3% difference)
3563
+ ```
3564
+
3565
+ ### SEO Best Practices
3566
+
3567
+ #### Use 301 for Permanent Changes
3568
+
3569
+ ```ruby
3570
+ # ✅ GOOD: Content permanently moved
3571
+ redirects_from '/old-product-page', status: 301 # Default
3572
+
3573
+ # ❌ BAD: Using 302 when content permanently moved
3574
+ redirects_from '/old-product-page', status: 302
3575
+ ```
3576
+
3577
+ #### Use 302 for Temporary Changes
3578
+
3579
+ ```ruby
3580
+ # ✅ GOOD: Temporary maintenance
3581
+ redirects_from '/dashboard', status: 302
3582
+
3583
+ # ✅ GOOD: A/B testing
3584
+ redirects_from '/promo', status: 302
3585
+ ```
3586
+
3587
+ #### Avoid Redirect Chains
3588
+
3589
+ ```ruby
3590
+ # ❌ BAD: Multiple hops
3591
+ /a → /b → /c
3592
+
3593
+ # ✅ GOOD: Direct redirect
3594
+ /a → /c
3595
+ /b → /c
3596
+ ```
3597
+
3598
+ #### Update Internal Links
3599
+
3600
+ After creating redirects, update your internal links:
3601
+
3602
+ ```ruby
3603
+ # ❌ BAD: Internal link that redirects
3604
+ <a href="/old-about">About</a>
3605
+
3606
+ # ✅ GOOD: Direct link
3607
+ <a href="/about">About</a>
3608
+ ```
3609
+
3610
+ ### Common Use Cases
3611
+
3612
+ #### 1. Rebranding
3613
+
3614
+ ```ruby
3615
+ class HomeHandler
3616
+ extend Aris::RouteHelpers
3617
+
3618
+ # Old brand URLs redirect to new brand
3619
+ redirects_from(
3620
+ '/old-company-name',
3621
+ '/old-brand',
3622
+ '/old-logo-page'
3623
+ )
3624
+ end
3625
+ ```
3626
+
3627
+ #### 2. Content Consolidation
3628
+
3629
+ ```ruby
3630
+ class GuideHandler
3631
+ extend Aris::RouteHelpers
3632
+
3633
+ # Multiple old guides consolidated into one
3634
+ redirects_from(
3635
+ '/guide-part-1',
3636
+ '/guide-part-2',
3637
+ '/guide-part-3',
3638
+ '/old-tutorial'
3639
+ )
3640
+
3641
+ def self.call(request, response)
3642
+ response.html(render_comprehensive_guide)
3643
+ end
3644
+ end
3645
+ ```
3646
+
3647
+ #### 3. URL Cleanup
3648
+
3649
+ ```ruby
3650
+ class ProductHandler
3651
+ extend Aris::RouteHelpers
3652
+
3653
+ # Clean up messy old URLs
3654
+ redirects_from(
3655
+ '/product_detail.php?id=123',
3656
+ '/products.aspx?productId=123',
3657
+ '/shop/product/123/view',
3658
+ '/catalog/item/123'
3659
+ )
3660
+
3661
+ def self.call(request, response)
3662
+ # Clean URL: /products/123
3663
+ end
3664
+ end
3665
+ ```
3666
+
3667
+ #### 4. Mobile/Desktop Unification
3668
+
3669
+ ```ruby
3670
+ class HomeHandler
3671
+ extend Aris::RouteHelpers
3672
+
3673
+ # Unified responsive site
3674
+ redirects_from(
3675
+ '/m/', # Old mobile homepage
3676
+ '/mobile',
3677
+ '/desktop'
3678
+ )
3679
+ end
3680
+ ```
3681
+
3682
+ #### 5. HTTP to HTTPS
3683
+
3684
+ ```ruby
3685
+ # Handle at proxy/CDN level (Cloudflare, nginx, etc.)
3686
+ # But if needed in app:
3687
+
3688
+ class Handler
3689
+ def self.call(request, response)
3690
+ if request.scheme == 'http'
3691
+ https_url = request.url.sub('http://', 'https://')
3692
+ return [301, {'location' => https_url}, []]
3693
+ end
3694
+
3695
+ # Normal handler logic
3696
+ end
3697
+ end
3698
+ ```
3699
+
3700
+ ### Wildcard Redirects
3701
+
3702
+ For pattern-based redirects, use handler logic:
3703
+
3704
+ ```ruby
3705
+ class BlogHandler
3706
+ def self.call(request, response)
3707
+ path = request.path
3708
+
3709
+ # Redirect old dated URLs to new slug-only URLs
3710
+ # /blog/2023/01/15/my-post → /blog/my-post
3711
+ if path =~ %r{^/blog/\d{4}/\d{2}/\d{2}/(.+)$}
3712
+ slug = $1
3713
+ return [301, {'location' => "/blog/#{slug}"}, []]
3714
+ end
3715
+
3716
+ # Normal handler logic
3717
+ post = BlogPost.find_by_slug(request.params[:slug])
3718
+ response.html(render_post(post))
3719
+ end
3720
+ end
3721
+ ```
3722
+
3723
+ ### Redirect Logging
3724
+
3725
+ Log redirects for monitoring:
3726
+
3727
+ ```ruby
3728
+ class LoggingHandler
3729
+ def self.call(request, response)
3730
+ # Check if this request was redirected
3731
+ original_path = request.headers['HTTP_X_ORIGINAL_URL']
3732
+
3733
+ if original_path
3734
+ logger.info "Redirect: #{original_path} → #{request.path}"
3735
+ end
3736
+
3737
+ # Normal handler logic
3738
+ end
3739
+ end
3740
+ ```
3741
+
3742
+ ### Bulk Import
3743
+
3744
+ Import redirects from CSV or database:
3745
+
3746
+ ```ruby
3747
+ # Import from CSV
3748
+ require 'csv'
3749
+
3750
+ CSV.foreach('redirects.csv', headers: true) do |row|
3751
+ Aris::Utils::Redirects.register(
3752
+ from_paths: row['old_url'],
3753
+ to_path: row['new_url'],
3754
+ status: row['status'].to_i
3755
+ )
3756
+ end
3757
+
3758
+ # Or from database
3759
+ Redirect.all.each do |redirect|
3760
+ Aris::Utils::Redirects.register(
3761
+ from_paths: redirect.from_path,
3762
+ to_path: redirect.to_path,
3763
+ status: redirect.status_code
3764
+ )
3765
+ end
3766
+ ```
3767
+
3768
+ ### Configuration
3769
+
3770
+ ```ruby
3771
+ # In your application setup
3772
+ Aris.configure do |config|
3773
+ # Enable redirect logging (if implemented)
3774
+ config.redirects.log = true
3775
+
3776
+ # Maximum redirects to store (prevents memory issues)
3777
+ config.redirects.max_count = 10_000
3778
+
3779
+ # Default status code
3780
+ config.redirects.default_status = 301
3781
+ end
3782
+ ```
3783
+
3784
+ ### Complete Example
3785
+
3786
+ Here's a full example showing redirects in action:
3787
+
3788
+ ```ruby
3789
+ # Handler
3790
+ class ProductHandler
3791
+ extend Aris::RouteHelpers
3792
+
3793
+ # Old URLs from previous site versions
3794
+ redirects_from(
3795
+ '/products_old/electronics/123',
3796
+ '/shop-old/item-123',
3797
+ '/store/product/123',
3798
+ '/catalog/item123.html',
3799
+ status: 301 # Permanent redirect
3800
+ )
3801
+
3802
+ def self.call(request, response)
3803
+ product = Product.find(request.params[:id])
3804
+
3805
+ # Additional logic: redirect if product has canonical URL
3806
+ if product.canonical_slug != request.params[:id]
3807
+ canonical_url = Aris.path(:product, id: product.canonical_slug)
3808
+ return [301, {'location' => canonical_url}, []]
3809
+ end
3810
+
3811
+ response.html(render_product(product))
3812
+ end
3813
+
3814
+ private
3815
+
3816
+ def self.render_product(product)
3817
+ <<~HTML
3818
+ <!DOCTYPE html>
3819
+ <html>
3820
+ <head>
3821
+ <title>#{product.name}</title>
3822
+ <link rel="canonical" href="#{product.canonical_url}">
3823
+ </head>
3824
+ <body>
3825
+ <h1>#{product.name}</h1>
3826
+ <p>#{product.description}</p>
3827
+ <button>Add to Cart</button>
3828
+ </body>
3829
+ </html>
3830
+ HTML
3831
+ end
3832
+ end
3833
+
3834
+ # Routes
3835
+ Aris.routes({
3836
+ "shop.example.com" => {
3837
+ "/products/:id" => {
3838
+ get: { to: ProductHandler, as: :product }
3839
+ }
3840
+ }
3841
+ })
3842
+
3843
+ # Results:
3844
+ # /products_old/electronics/123 → 301 → /products/123
3845
+ # /shop-old/item-123 → 301 → /products/123
3846
+ # /store/product/123 → 301 → /products/123
3847
+ # /catalog/item123.html → 301 → /products/123
3848
+ # /products/123 → ProductHandler (200 OK)
3849
+ ```
3850
+
3851
+ ### Troubleshooting
3852
+
3853
+ **Redirect not working:**
3854
+ - Verify path exactly matches (including leading `/`)
3855
+ - Check redirect is registered: `Aris::Utils::Redirects.find('/old-path')`
3856
+ - Ensure adapters are checking redirects (should be automatic)
3857
+
3858
+ **Redirect loops:**
3859
+ - Check target path doesn't also have redirect
3860
+ - Use `Aris::Utils::Redirects.all` to inspect all redirects
3861
+
3862
+ **Redirects not persisting:**
3863
+ - Redirects reset on each `Aris.routes()` call
3864
+ - Ensure redirects declared in route definition or handler
3865
+ - For file discovery, ensure handler has `redirects_from` declaration
3866
+
3867
+ ---
3868
+
3869
+ # Trailing Slash Handling - Concise Guide
3870
+
3871
+ Add this section to your README.md:
3872
+
3873
+ ---
3874
+
3875
+ ## Trailing Slash Handling
3876
+
3877
+ Control how Aris handles URLs with trailing slashes. Set once, applies everywhere.
3878
+
3879
+ ### Quick Start
3880
+
3881
+ ```ruby
3882
+ Aris.configure do |config|
3883
+ config.trailing_slash = :redirect # or :ignore, :strict
3884
+ end
3885
+ ```
3886
+
3887
+ ### Three Modes
3888
+
3889
+ **`:redirect` - SEO-friendly (recommended)**
3890
+ ```ruby
3891
+ config.trailing_slash = :redirect
3892
+
3893
+ GET /about/ → 301 → /about
3894
+ GET /about → 200 OK
3895
+ ```
3896
+ Use when: You want clean, canonical URLs without trailing slashes.
3897
+
3898
+ **`:ignore` - Flexible**
3899
+ ```ruby
3900
+ config.trailing_slash = :ignore
3901
+
3902
+ GET /about/ → 200 OK
3903
+ GET /about → 200 OK (same handler)
3904
+ ```
3905
+ Use when: You don't care about trailing slashes (APIs, internal tools).
3906
+
3907
+ **`:strict` - Explicit (default)**
3908
+ ```ruby
3909
+ config.trailing_slash = :strict
3910
+
3911
+ GET /about/ → 404 (unless you define this route)
3912
+ GET /about → 200 OK
3913
+ ```
3914
+ Use when: You need explicit control over each URL.
3915
+
3916
+ ### Configuration
3917
+
3918
+ ```ruby
3919
+ # config.ru
3920
+ Aris.configure do |config|
3921
+ config.trailing_slash = :redirect
3922
+ config.trailing_slash_status = 301 # Optional: 301 (default) or 302
3923
+ end
3924
+
3925
+ Aris.routes({
3926
+ "example.com" => {
3927
+ "/about" => { get: { to: AboutHandler } }
3928
+ }
3929
+ })
3930
+ ```
3931
+
3932
+ ### Handler Transparency
3933
+
3934
+ Handlers don't need to change - they work the same regardless of mode:
3935
+
3936
+ ```ruby
3937
+ class AboutHandler
3938
+ def self.call(request, response)
3939
+ # Works whether user visits /about or /about/
3940
+ response.html("<h1>About Us</h1>")
3941
+ end
3942
+ end
3943
+ ```
3944
+
3945
+ ### Works With Everything
3946
+
3947
+ **Localized routes:**
3948
+ ```ruby
3949
+ GET /en/about/ → 301 → /en/about
3950
+ GET /es/acerca/ → 301 → /es/acerca
3951
+ ```
3952
+
3953
+ **Parameterized routes:**
3954
+ ```ruby
3955
+ GET /products/123/ → 301 → /products/123
3956
+ ```
3957
+
3958
+ **Root path always works:**
3959
+ ```ruby
3960
+ GET / → Always 200 OK (never redirected)
3961
+ ```
3962
+
3963
+ ### Which Mode Should I Use?
3964
+
3965
+ | Use Case | Mode |
3966
+ |----------|------|
3967
+ | Public website with SEO | `:redirect` |
3968
+ | REST API | `:ignore` |
3969
+ | Need explicit control | `:strict` |
3970
+ | Don't know yet | `:redirect` |
3971
+
3972
+ ---
3973
+
3974
+ Ah, I understand! You want the ResponseHelpers to provide convenience methods that can be called on the response object within handlers. Let me write the README section for that:
3975
+
3976
+ # Response Helpers
3977
+
3978
+ Aris provides convenient response helper methods that make building HTTP responses clean and expressive. The response object is automatically available in your handlers and includes these helpful methods.
3979
+
3980
+ ## Basic Usage
3981
+
3982
+ When you receive the response object in your handler, you can use these fluent interface methods:
3983
+
3984
+ ```ruby
3985
+ Aris.routes({
3986
+ "example.com" => {
3987
+ "/api" => {
3988
+ get: {
3989
+ to: ->(req, res, params) {
3990
+ # JSON response with fluent interface
3991
+ res.json({ message: "Hello World" })
3992
+ }
3993
+ }
3994
+ }
3995
+ }
3996
+ })
3997
+ ```
3998
+
3999
+ ## Available Helpers
4000
+
4001
+ ### JSON Responses
4002
+ ```ruby
4003
+ res.json({ data: "value" })
4004
+ res.json({ error: "Not found" }, status: 404)
4005
+ ```
4006
+
4007
+ ### HTML Responses
4008
+ ```ruby
4009
+ res.html("<h1>Welcome</h1>")
4010
+ res.html("<p>Error</p>", status: 500)
4011
+ ```
4012
+
4013
+ ### Plain Text Responses
4014
+ ```ruby
4015
+ res.text("Hello World")
4016
+ res.text("Created", status: 201)
4017
+ ```
4018
+
4019
+ ### Redirects
4020
+ ```ruby
4021
+ res.redirect("/new-path") # 302 redirect
4022
+ res.redirect("/permanent", status: 301) # 301 redirect
4023
+ res.redirect_to(:user_profile, id: 123) # Redirect to named route
4024
+ ```
4025
+
4026
+ ### No Content
4027
+ ```ruby
4028
+ res.no_content # Returns 204 with empty body
4029
+ ```
4030
+
4031
+ ### XML Responses
4032
+ ```ruby
4033
+ res.xml("<root><item>1</item></root>")
4034
+ ```
4035
+
4036
+ ### File Downloads
4037
+ ```ruby
4038
+ res.send_file("/path/to/file.pdf")
4039
+ res.send_file("/path/to/file.jpg", filename: "image.jpg")
4040
+ res.send_file("/path/to/file.txt", type: "text/plain", disposition: "inline")
4041
+ ```
4042
+
4043
+ ## Complete Examples
4044
+
4045
+ ### API Endpoint
4046
+ ```ruby
4047
+ to: ->(req, res, params) {
4048
+ user = find_user(params[:id])
4049
+ if user
4050
+ res.json({ user: user.attributes })
4051
+ else
4052
+ res.json({ error: "User not found" }, status: 404)
4053
+ end
4054
+ }
4055
+ ```
4056
+
4057
+ ### Web Page
4058
+ ```ruby
4059
+ to: ->(req, res, params) {
4060
+ res.html(render_template("page.html", params))
4061
+ }
4062
+ ```
4063
+
4064
+ ### Redirect Flow
4065
+ ```ruby
4066
+ to: ->(req, res, params) {
4067
+ if authenticated?(req)
4068
+ res.redirect_to(:dashboard)
4069
+ else
4070
+ res.redirect_to(:login, return_to: req.path)
4071
+ end
4072
+ }
4073
+ ```
4074
+
4075
+ ### File Download
4076
+ ```ruby
4077
+ to: ->(req, res, params) {
4078
+ if can_download?(req)
4079
+ res.send_file("/files/#{params[:filename]}")
4080
+ else
4081
+ res.json({ error: "Access denied" }, status: 403)
4082
+ end
4083
+ }
4084
+ ```
4085
+
4086
+ ## Fluent Interface
4087
+
4088
+ All response helpers return the response object, allowing method chaining:
4089
+
4090
+ ```ruby
4091
+ # You can chain if needed (though usually not necessary)
4092
+ response = res.json({ status: "ok" }).tap { |r|
4093
+ r.headers["X-Custom"] = "value"
4094
+ }
4095
+ ```
4096
+ # Content Negotiation Helper
4097
+
4098
+ Aris provides a clean `negotiate` helper that makes content negotiation simple and expressive. Handle multiple response formats with a single, readable block.
4099
+
4100
+ ## Basic Usage
4101
+
4102
+ ```ruby
4103
+ to: ->(req, res, params) {
4104
+ user = find_user(params[:id])
4105
+
4106
+ res.negotiate(req.format) do |format|
4107
+ case format
4108
+ when :json then user.attributes
4109
+ when :xml then user.to_xml
4110
+ when :html then render_template('user.html', user: user)
4111
+ end
4112
+ end
4113
+ }
4114
+ ```
4115
+
4116
+ ## Automatic Format Detection
4117
+
4118
+ The helper automatically detects formats from:
4119
+ - **Symbols**: `:json`, `:xml`, `:html`
4120
+ - **MIME types**: `'application/json'`, `'text/html'`, etc.
4121
+ - **Defaults to JSON** for unknown formats
4122
+
4123
+ ```ruby
4124
+ # All these work the same way:
4125
+ res.negotiate(:json) { |f| { data: "value" } }
4126
+ res.negotiate('application/json') { |f| { data: "value" } }
4127
+ res.negotiate(req.headers['HTTP_ACCEPT']) { |f| { data: "value" } }
4128
+ ```
4129
+
4130
+ ## Custom Status Codes
4131
+
4132
+ Set HTTP status codes while negotiating content:
4133
+
4134
+ ```ruby
4135
+ res.negotiate(:json, status: 404) do |format|
4136
+ case format
4137
+ when :json then { error: "User not found" }
4138
+ when :xml then "<error>User not found</error>"
4139
+ when :html then "<h1>404 - User Not Found</h1>"
4140
+ end
4141
+ end
4142
+ ```
4143
+
4144
+ ## Real-World Example
4145
+
4146
+ ```ruby
4147
+ Aris.routes({
4148
+ "api.example.com" => {
4149
+ "/users/:id" => {
4150
+ get: {
4151
+ to: ->(req, res, params) {
4152
+ user = User.find(params[:id])
4153
+
4154
+ if user
4155
+ res.negotiate(req.format) do |format|
4156
+ case format
4157
+ when :json then user.attributes
4158
+ when :xml then user.to_xml
4159
+ when :html then render_template('user_profile.html', user: user)
4160
+ end
4161
+ end
4162
+ else
4163
+ res.negotiate(req.format, status: 404) do |format|
4164
+ case format
4165
+ when :json then { error: "User not found" }
4166
+ when :xml then "<error>User not found</error>"
4167
+ when :html then "<h1>User Not Found</h1>"
4168
+ end
4169
+ end
4170
+ end
4171
+ }
4172
+ }
4173
+ }
4174
+ }
4175
+ })
4176
+ ```
4177
+
4178
+ ## Benefits
4179
+
4180
+ - **Clean & Readable**: No nested conditionals for format handling
4181
+ - **Consistent**: Uses your existing response helpers under the hood
4182
+ - **Flexible**: Works with any format you define in the block
4183
+ - **Type-Smart**: Automatically handles pre-encoded JSON strings
4184
+
4185
+
4186
+ The response helpers provide a clean, expressive way to build HTTP responses while maintaining the power and flexibility of the underlying Rack architecture.
4187
+
4188
+ ---
4189
+
4190
+ # Cookie Plugin
4191
+
4192
+ The Cookie plugin provides a consistent way to read and write cookies across both development (Mock adapter) and production (Rack adapter) environments.
4193
+
4194
+ ## Installation
4195
+
4196
+ The cookie plugin is built into Aris. Enable it in your routes using the `use` array:
4197
+
4198
+ ```ruby
4199
+ Aris.routes({
4200
+ "example.com" => {
4201
+ use: [:cookies], # Enable cookie functionality
4202
+ # ... your routes
4203
+ }
4204
+ })
4205
+ ```
4206
+
4207
+ ## Reading Cookies
4208
+
4209
+ Cookies from incoming requests are automatically parsed and available via `req.cookies`:
4210
+
4211
+ ```ruby
4212
+ Aris.routes({
4213
+ "example.com" => {
4214
+ "/dashboard" => {
4215
+ get: {
4216
+ to: ->(req, res, params) {
4217
+ # Read cookies (works even without the plugin)
4218
+ user_id = req.cookies['user_id']
4219
+ theme = req.cookies['theme']
4220
+
4221
+ res.text("Welcome user #{user_id} with #{theme} theme!")
4222
+ }
4223
+ }
4224
+ }
4225
+ }
4226
+ })
4227
+ ```
4228
+
4229
+ ## Writing Cookies
4230
+
4231
+ When the cookie plugin is enabled, you can set cookies using `res.set_cookie`:
4232
+
4233
+ ```ruby
4234
+ Aris.routes({
4235
+ "example.com" => {
4236
+ use: [:cookies],
4237
+ "/login" => {
4238
+ post: {
4239
+ to: ->(req, res, params) {
4240
+ # Set cookies with the plugin
4241
+ res.set_cookie('user_id', '123')
4242
+ res.set_cookie('theme', 'dark')
4243
+
4244
+ res.redirect('/dashboard')
4245
+ }
4246
+ }
4247
+ }
4248
+ }
4249
+ })
4250
+ ```
4251
+
4252
+ ## Cookie Options
4253
+
4254
+ Configure cookies with security and expiration options:
4255
+
4256
+ ```ruby
4257
+ res.set_cookie('session', 'abc123', {
4258
+ httponly: true, # Prevent JavaScript access
4259
+ secure: true, # HTTPS only (recommended for production)
4260
+ max_age: 3600, # Expires in 1 hour (in seconds)
4261
+ path: '/admin', # Only sent to /admin paths
4262
+ same_site: 'lax' # CSRF protection
4263
+ })
4264
+ ```
4265
+
4266
+ ## Deleting Cookies
4267
+
4268
+ Remove cookies by setting them to expire immediately:
4269
+
4270
+ ```ruby
4271
+ res.delete_cookie('user_id')
4272
+ res.delete_cookie('session', { path: '/admin' }) # With specific path
4273
+ ```
4274
+
4275
+ ## Global Configuration
4276
+
4277
+ Set default cookie options for your entire application:
4278
+
4279
+ ```ruby
4280
+ Aris.configure do |config|
4281
+ config.cookie_options = {
4282
+ httponly: true,
4283
+ secure: (ENV['RACK_ENV'] == 'production'), # Auto-enable HTTPS in production
4284
+ same_site: :lax,
4285
+ path: '/',
4286
+ max_age: 86400 # 1 day default
4287
+ }
4288
+ end
4289
+ ```
4290
+
4291
+ Individual `set_cookie` calls can override these defaults.
4292
+
4293
+ ## Complete Example
4294
+
4295
+ ```ruby
4296
+ Aris.routes({
4297
+ "example.com" => {
4298
+ use: [:cookies],
4299
+
4300
+ "/login" => {
4301
+ post: {
4302
+ to: ->(req, res, params) {
4303
+ # Authenticate user...
4304
+ user = authenticate(params[:email], params[:password])
4305
+
4306
+ # Set secure session cookies
4307
+ res.set_cookie('user_id', user.id, { httponly: true })
4308
+ res.set_cookie('session_token', generate_token(user), {
4309
+ httponly: true,
4310
+ secure: true,
4311
+ max_age: 7 * 24 * 3600 # 1 week
4312
+ })
4313
+
4314
+ res.redirect('/dashboard')
4315
+ }
4316
+ }
4317
+ },
4318
+
4319
+ "/dashboard" => {
4320
+ get: {
4321
+ to: ->(req, res, params) {
4322
+ # Read user from cookies
4323
+ user_id = req.cookies['user_id']
4324
+ user = User.find(user_id)
4325
+
4326
+ res.text("Welcome #{user.name}!")
4327
+ }
4328
+ }
4329
+ },
4330
+
4331
+ "/settings" => {
4332
+ post: {
4333
+ to: ->(req, res, params) {
4334
+ # Update user preference
4335
+ res.set_cookie('theme', params[:theme], { max_age: 365 * 24 * 3600 })
4336
+ res.redirect('/dashboard')
4337
+ }
4338
+ }
4339
+ },
4340
+
4341
+ "/logout" => {
4342
+ post: {
4343
+ to: ->(req, res, params) {
4344
+ # Clear all session cookies
4345
+ res.delete_cookie('user_id')
4346
+ res.delete_cookie('session_token')
4347
+
4348
+ res.redirect('/')
4349
+ }
4350
+ }
4351
+ }
4352
+ }
4353
+ })
4354
+ ```
4355
+
4356
+ ## Testing
4357
+
4358
+ Cookies work identically in tests and production:
4359
+
4360
+ ```ruby
4361
+ # In your tests
4362
+ def test_login_flow
4363
+ adapter = Aris::Adapters::Mock::Adapter.new
4364
+
4365
+ # Login request
4366
+ response = adapter.call(
4367
+ method: :post,
4368
+ path: '/login',
4369
+ domain: 'example.com',
4370
+ body: { email: 'user@example.com', password: 'secret' }
4371
+ )
4372
+
4373
+ # Verify cookies are set
4374
+ assert_match(/user_id=/, response[:headers]['Set-Cookie'])
4375
+ assert_match(/session_token=/, response[:headers]['Set-Cookie'])
4376
+
4377
+ # Subsequent request with cookies
4378
+ response = adapter.call(
4379
+ method: :get,
4380
+ path: '/dashboard',
4381
+ domain: 'example.com',
4382
+ headers: { 'Cookie' => 'user_id=123; session_token=abc' }
4383
+ )
4384
+
4385
+ assert_equal 200, response[:status]
4386
+ end
4387
+ ```
4388
+
4389
+ ## Notes
4390
+
4391
+ - **Reading cookies** works everywhere (built into adapters)
4392
+ - **Writing cookies** requires the `use: [:cookies]` plugin
4393
+ - Cookies are automatically parsed from incoming requests
4394
+ - Cookie writing methods are added to the response object
4395
+ - Both Mock and Rack adapters provide identical behavior
4396
+
4397
+ This design ensures cookie functionality is available where needed while maintaining security and testability.
4398
+
4399
+ ---
4400
+
4401
+ # Flash Plugin
4402
+
4403
+ The Flash plugin provides Rails-like flash messaging for persisting data across redirects and displaying one-time messages to users.
4404
+
4405
+ ## Installation
4406
+
4407
+ The flash plugin is built into Aris. Enable it in your routes using the `use` array along with cookies:
4408
+
4409
+ ```ruby
4410
+ Aris.routes({
4411
+ "example.com" => {
4412
+ use: [:cookies, :flash], # Enable both cookies and flash
4413
+ # ... your routes
4414
+ }
4415
+ })
4416
+ ```
4417
+
4418
+ ## Basic Usage
4419
+
4420
+ ### Regular Flash (Persists Across Redirects)
4421
+
4422
+ ```ruby
4423
+ Aris.routes({
4424
+ "example.com" => {
4425
+ use: [:cookies, :flash],
4426
+ "/create-user" => {
4427
+ post: {
4428
+ to: ->(req, res, params) {
4429
+ # Create user logic...
4430
+ req.flash[:notice] = "User created successfully!"
4431
+ req.flash[:alert] = "Welcome to our application"
4432
+ res.redirect("/dashboard")
4433
+ }
4434
+ }
4435
+ },
4436
+ "/dashboard" => {
4437
+ get: {
4438
+ to: ->(req, res, params) {
4439
+ # Read flash messages (automatically cleared after reading)
4440
+ notice = req.flash[:notice] # "User created successfully!"
4441
+ alert = req.flash[:alert] # "Welcome to our application"
4442
+
4443
+ # Second read returns nil (flash is cleared)
4444
+ notice_again = req.flash[:notice] # nil
4445
+
4446
+ res.text("Notice: #{notice}, Alert: #{alert}")
4447
+ }
4448
+ }
4449
+ }
4450
+ }
4451
+ })
4452
+ ```
4453
+
4454
+ ### Flash.now (Current Request Only)
4455
+
4456
+ ```ruby
4457
+ Aris.routes({
4458
+ "example.com" => {
4459
+ use: [:cookies, :flash],
4460
+ "/form-with-errors" => {
4461
+ post: {
4462
+ to: ->(req, res, params) {
4463
+ # Flash.now only available in current request
4464
+ req.flash.now[:error] = "Please fix the errors below"
4465
+ current_error = req.flash.now[:error] # Available now
4466
+
4467
+ # Render form with errors (no redirect)
4468
+ res.text("Error: #{current_error}")
4469
+ }
4470
+ }
4471
+ }
4472
+ }
4473
+ })
4474
+ ```
4475
+
4476
+ ## Key Features
4477
+
4478
+ ### Automatic Clearing
4479
+ Flash messages are automatically cleared after being read:
4480
+
4481
+ ```ruby
4482
+ # First request sets flash
4483
+ req.flash[:message] = "Hello World"
4484
+ res.redirect("/read")
4485
+
4486
+ # Second request reads flash
4487
+ first_read = req.flash[:message] # "Hello World"
4488
+ second_read = req.flash[:message] # nil (cleared after first read)
4489
+ ```
4490
+
4491
+ ### Multiple Message Types
4492
+ Support for different flash categories:
4493
+
4494
+ ```ruby
4495
+ req.flash[:notice] = "Operation completed"
4496
+ req.flash[:alert] = "Please check your email"
4497
+ req.flash[:error] = "Something went wrong"
4498
+ ```
4499
+
4500
+ ### Flash.now vs Regular Flash
4501
+
4502
+ ```ruby
4503
+ # Regular flash - persists to next request
4504
+ req.flash[:persistent] = "I survive redirects"
4505
+
4506
+ # Flash.now - only current request
4507
+ req.flash.now[:temporary] = "I disappear after this request"
4508
+
4509
+ # In the same request:
4510
+ req.flash[:persistent] # "I survive redirects"
4511
+ req.flash.now[:temporary] # "I disappear after this request"
4512
+
4513
+ # After redirect:
4514
+ req.flash[:persistent] # "I survive redirects"
4515
+ req.flash[:temporary] # nil (flash.now doesn't persist)
4516
+ ```
4517
+
4518
+ ## Complete Example
4519
+
4520
+ ```ruby
4521
+ Aris.routes({
4522
+ "example.com" => {
4523
+ use: [:cookies, :flash],
4524
+
4525
+ "/login" => {
4526
+ get: {
4527
+ to: ->(req, res, params) {
4528
+ # Show login form with any flash messages
4529
+ notice = req.flash[:notice]
4530
+ error = req.flash[:error]
4531
+ res.text("Notice: #{notice}, Error: #{error}")
4532
+ }
4533
+ },
4534
+ post: {
4535
+ to: ->(req, res, params) {
4536
+ if authenticate(params[:email], params[:password])
4537
+ req.flash[:notice] = "Successfully logged in!"
4538
+ res.redirect("/dashboard")
4539
+ else
4540
+ req.flash.now[:error] = "Invalid email or password"
4541
+ res.text("Login failed: #{req.flash.now[:error]}")
4542
+ end
4543
+ }
4544
+ }
4545
+ },
4546
+
4547
+ "/logout" => {
4548
+ post: {
4549
+ to: ->(req, res, params) {
4550
+ req.flash[:notice] = "Successfully logged out"
4551
+ res.redirect("/")
4552
+ }
4553
+ }
4554
+ }
4555
+ }
4556
+ })
4557
+ ```
4558
+
4559
+ ## Testing
4560
+
4561
+ Flash works identically in tests and production:
4562
+
4563
+ ```ruby
4564
+ def test_login_success_flash
4565
+ adapter = Aris::Adapters::Mock::Adapter.new
4566
+
4567
+ # Login request
4568
+ response1 = adapter.call(
4569
+ method: :post,
4570
+ path: '/login',
4571
+ domain: 'example.com',
4572
+ body: { email: 'user@example.com', password: 'secret' }
4573
+ )
4574
+
4575
+ # Extract flash cookie from redirect
4576
+ set_cookie = response1[:headers]['Set-Cookie']
4577
+ cookie_value = set_cookie.match(/_aris_flash=([^;]+)/)[1]
4578
+
4579
+ # Follow redirect to dashboard
4580
+ response2 = adapter.call(
4581
+ method: :get,
4582
+ path: '/dashboard',
4583
+ domain: 'example.com',
4584
+ headers: { 'Cookie' => "_aris_flash=#{cookie_value}" }
4585
+ )
4586
+
4587
+ # Verify flash message is displayed
4588
+ assert_includes response2[:body].first, "Successfully logged in!"
4589
+ end
4590
+ ```
4591
+
4592
+ ## How It Works
4593
+
4594
+ - **Storage**: Flash data is stored in cookies (supports signed cookies if available)
4595
+ - **Persistence**: Regular flash survives one redirect, then is automatically cleared
4596
+ - **Security**: Uses same cookie security settings as your cookie configuration
4597
+ - **Automatic Cleanup**: No manual cleanup needed - flash clears itself
4598
+
4599
+ ## Notes
4600
+
4601
+ - Requires the `cookies` plugin to be enabled
4602
+ - Flash messages are automatically cleared after being read once
4603
+ - `flash.now` is perfect for form validation errors and current request messages
4604
+ - Regular `flash` is ideal for success messages and redirect notifications
4605
+
4606
+ This provides a clean, familiar flash messaging system that works seamlessly across your Aris application!
4607
+
4608
+ ---
4609
+
4610
+ # Session Plugin
4611
+
4612
+ The Session plugin provides secure, persistent user state management across requests. It's essential for authentication, user preferences, and maintaining application state.
4613
+
4614
+ ## Installation
4615
+
4616
+ The session plugin is built into Aris. Enable it in your routes along with cookies:
4617
+
4618
+ ```ruby
4619
+ Aris.routes({
4620
+ "example.com" => {
4621
+ use: [:cookies, :session], # Enable both cookies and session
4622
+ # ... your routes
4623
+ }
4624
+ })
4625
+ ```
4626
+
4627
+ ## Configuration
4628
+
4629
+ Configure session behavior globally:
4630
+
4631
+ ```ruby
4632
+ Aris.configure do |config|
4633
+ config.secret_key_base = 'your-secret-key-here' # Required for encryption
4634
+ config.session = {
4635
+ key: '_aris_session', # Cookie name
4636
+ expire_after: 14 * 24 * 3600, # 2 weeks in seconds
4637
+ store: :cookie # Storage backend
4638
+ }
4639
+ end
4640
+ ```
4641
+
4642
+ ## Basic Usage
4643
+
4644
+ ### Storing Data in Session
4645
+
4646
+ ```ruby
4647
+ Aris.routes({
4648
+ "example.com" => {
4649
+ use: [:cookies, :session],
4650
+ "/login" => {
4651
+ post: {
4652
+ to: ->(req, res, params) {
4653
+ # Store user information in session
4654
+ req.session[:user_id] = 123
4655
+ req.session[:user_email] = 'user@example.com'
4656
+ req.session[:role] = 'admin'
4657
+
4658
+ res.redirect("/dashboard")
4659
+ }
4660
+ }
4661
+ }
4662
+ }
4663
+ })
4664
+ ```
4665
+
4666
+ ### Reading Data from Session
4667
+
4668
+ ```ruby
4669
+ Aris.routes({
4670
+ "example.com" => {
4671
+ use: [:cookies, :session],
4672
+ "/dashboard" => {
4673
+ get: {
4674
+ to: ->(req, res, params) {
4675
+ # Read from session
4676
+ user_id = req.session[:user_id]
4677
+ email = req.session[:user_email]
4678
+ role = req.session[:role]
4679
+
4680
+ res.text("Welcome user #{user_id} (#{email}) with role: #{role}")
4681
+ }
4682
+ }
4683
+ }
4684
+ }
4685
+ })
4686
+ ```
4687
+
4688
+ ### Managing Session Data
4689
+
4690
+ ```ruby
4691
+ # Delete specific keys
4692
+ req.session.delete(:role)
4693
+
4694
+ # Check if key exists
4695
+ if req.session[:user_id]
4696
+ # User is logged in
4697
+ end
4698
+
4699
+ # Clear entire session
4700
+ req.session.clear
4701
+
4702
+ # Destroy session (clears and marks for removal)
4703
+ req.session.destroy
4704
+ ```
4705
+
4706
+ ## Complete Authentication Flow
4707
+
4708
+ ```ruby
4709
+ Aris.routes({
4710
+ "example.com" => {
4711
+ use: [:cookies, :flash, :session], # Use all three for full functionality
4712
+
4713
+ "/login" => {
4714
+ get: {
4715
+ to: ->(req, res, params) {
4716
+ # Show login form
4717
+ error = req.flash[:error]
4718
+ res.text("Login form. Error: #{error}")
4719
+ }
4720
+ },
4721
+ post: {
4722
+ to: ->(req, res, params) {
4723
+ # Authentication logic
4724
+ user = authenticate(params[:email], params[:password])
4725
+
4726
+ if user
4727
+ # Store in session
4728
+ req.session[:user_id] = user.id
4729
+ req.session[:user_email] = user.email
4730
+
4731
+ # Set success message
4732
+ req.flash[:notice] = "Welcome back!"
4733
+ res.redirect("/dashboard")
4734
+ else
4735
+ # Show error
4736
+ req.flash.now[:error] = "Invalid credentials"
4737
+ res.text("Login failed")
4738
+ end
4739
+ }
4740
+ }
4741
+ },
4742
+
4743
+ "/dashboard" => {
4744
+ get: {
4745
+ to: ->(req, res, params) {
4746
+ # Check authentication via session
4747
+ unless req.session[:user_id]
4748
+ req.flash[:error] = "Please log in first"
4749
+ return res.redirect("/login")
4750
+ end
4751
+
4752
+ user_id = req.session[:user_id]
4753
+ notice = req.flash[:notice]
4754
+ res.text("Dashboard for user #{user_id}. #{notice}")
4755
+ }
4756
+ }
4757
+ },
4758
+
4759
+ "/profile" => {
4760
+ get: {
4761
+ to: ->(req, res, params) {
4762
+ # Access control with session
4763
+ user_id = req.session[:user_id]
4764
+ user = User.find(user_id) if user_id
4765
+
4766
+ if user
4767
+ res.text("Profile for #{user.email}")
4768
+ else
4769
+ res.redirect("/login")
4770
+ end
4771
+ }
4772
+ }
4773
+ },
4774
+
4775
+ "/logout" => {
4776
+ post: {
4777
+ to: ->(req, res, params) {
4778
+ # Clear session on logout
4779
+ req.session.destroy
4780
+
4781
+ req.flash[:notice] = "Successfully logged out"
4782
+ res.redirect("/")
4783
+ }
4784
+ }
4785
+ }
4786
+ }
4787
+ })
4788
+ ```
4789
+
4790
+ ## User Preferences Example
4791
+
4792
+ ```ruby
4793
+ Aris.routes({
4794
+ "example.com" => {
4795
+ use: [:cookies, :session],
4796
+
4797
+ "/settings" => {
4798
+ get: {
4799
+ to: ->(req, res, params) {
4800
+ # Read user preferences from session
4801
+ theme = req.session[:theme] || 'light'
4802
+ language = req.session[:language] || 'en'
4803
+
4804
+ res.text("Theme: #{theme}, Language: #{language}")
4805
+ }
4806
+ },
4807
+ post: {
4808
+ to: ->(req, res, params) {
4809
+ # Save user preferences to session
4810
+ req.session[:theme] = params[:theme]
4811
+ req.session[:language] = params[:language]
4812
+ req.session[:notifications] = params[:notifications] == 'on'
4813
+
4814
+ req.flash[:notice] = "Settings saved!"
4815
+ res.redirect("/settings")
4816
+ }
4817
+ }
4818
+ }
4819
+ }
4820
+ })
4821
+ ```
4822
+
4823
+ ## Shopping Cart Example
4824
+
4825
+ ```ruby
4826
+ Aris.routes({
4827
+ "example.com" => {
4828
+ use: [:cookies, :session],
4829
+
4830
+ "/cart" => {
4831
+ get: {
4832
+ to: ->(req, res, params) {
4833
+ # Initialize cart if not exists
4834
+ req.session[:cart] ||= []
4835
+ cart_items = req.session[:cart]
4836
+
4837
+ res.json(cart_items)
4838
+ }
4839
+ },
4840
+ post: {
4841
+ to: ->(req, res, params) {
4842
+ # Add item to cart
4843
+ req.session[:cart] ||= []
4844
+ req.session[:cart] << {
4845
+ id: params[:product_id],
4846
+ name: params[:product_name],
4847
+ price: params[:price],
4848
+ quantity: params[:quantity] || 1
4849
+ }
4850
+
4851
+ res.redirect("/cart")
4852
+ }
4853
+ }
4854
+ },
4855
+
4856
+ "/cart/clear" => {
4857
+ post: {
4858
+ to: ->(req, res, params) {
4859
+ # Clear cart
4860
+ req.session.delete(:cart)
4861
+
4862
+ res.redirect("/cart")
4863
+ }
4864
+ }
4865
+ }
4866
+ }
4867
+ })
4868
+ ```
4869
+
4870
+ ## Testing
4871
+
4872
+ Sessions work seamlessly in tests:
4873
+
4874
+ ```ruby
4875
+ def test_user_login_flow
4876
+ adapter = Aris::Adapters::Mock::Adapter.new
4877
+
4878
+ # Login request
4879
+ response1 = adapter.call(
4880
+ method: :post,
4881
+ path: '/login',
4882
+ domain: 'example.com',
4883
+ body: { email: 'user@example.com', password: 'secret' }
4884
+ )
4885
+
4886
+ # Extract session cookie
4887
+ set_cookie = response1[:headers]['Set-Cookie']
4888
+ session_cookie = set_cookie.split(', ').find { |c| c.include?('_aris_session') }
4889
+ cookie_value = session_cookie.match(/_aris_session=([^;]+)/)[1]
4890
+
4891
+ # Access protected route with session
4892
+ response2 = adapter.call(
4893
+ method: :get,
4894
+ path: '/dashboard',
4895
+ domain: 'example.com',
4896
+ headers: { 'Cookie' => "_aris_session=#{cookie_value}" }
4897
+ )
4898
+
4899
+ assert_equal 200, response2[:status]
4900
+ assert_includes response2[:body].first, "user@example.com"
4901
+ end
4902
+
4903
+ def test_user_logout
4904
+ adapter = Aris::Adapters::Mock::Adapter.new
4905
+
4906
+ # Logout should clear session
4907
+ response = adapter.call(
4908
+ method: :post,
4909
+ path: '/logout',
4910
+ domain: 'example.com'
4911
+ )
4912
+
4913
+ # Verify session cookie is cleared
4914
+ set_cookie = response[:headers]['Set-Cookie']
4915
+ assert_match(/Max-Age=0/, set_cookie)
4916
+ end
4917
+ ```
4918
+
4919
+ ## Security Features
4920
+
4921
+ - **Encrypted Storage**: Session data is encrypted in cookies
4922
+ - **HTTP Only**: Sessions cannot be accessed via JavaScript
4923
+ - **Secure Cookies**: Automatic HTTPS in production
4924
+ - **Expiration**: Configurable session lifetime
4925
+ - **Secret Key**: Requires `secret_key_base` for encryption
4926
+
4927
+ ## Session vs Cookies vs Flash
4928
+
4929
+ | Feature | Session | Cookies | Flash |
4930
+ |---------|---------|---------|-------|
4931
+ | **Purpose** | User state | Client storage | One-time messages |
4932
+ | **Persistence** | Until logout/expiry | Until expiry | One read |
4933
+ | **Security** | Encrypted | Plain text | Plain text |
4934
+ | **Use Case** | Authentication | Preferences | Notifications |
4935
+
4936
+ ## Best Practices
4937
+
4938
+ 1. **Keep sessions small** - Store only essential data (user IDs, not entire objects)
4939
+ 2. **Use for authentication** - Perfect for login/logout flows
4940
+ 3. **Combine with flash** - Use flash for messages, session for state
4941
+ 4. **Set reasonable expiration** - Balance security and convenience
4942
+ 5. **Always destroy on logout** - Clear session data properly
4943
+
4944
+ Sessions complete Aris's state management story, enabling robust authentication and user-specific functionality in your applications!
4945
+
4946
+ ---
4947
+
4948
+ # Subdomain Wildcards
4949
+
4950
+ Aris supports wildcard subdomain routing for multi-tenant applications, white-labeling, and organization-specific routing.
4951
+
4952
+ ## Basic Usage
4953
+
4954
+ ### Wildcard Subdomain Routes
4955
+
4956
+ ```ruby
4957
+ Aris.routes({
4958
+ # Catch-all for any subdomain
4959
+ "*.example.com" => {
4960
+ "/" => {
4961
+ get: {
4962
+ to: ->(req, res, params) {
4963
+ tenant = req.subdomain # "acme" from acme.example.com
4964
+ res.text("Welcome to #{tenant}'s site!")
4965
+ }
4966
+ }
4967
+ },
4968
+ "/dashboard" => {
4969
+ get: {
4970
+ to: ->(req, res, params) {
4971
+ tenant = req.subdomain
4972
+ res.text("#{tenant}'s dashboard")
4973
+ }
4974
+ }
4975
+ }
4976
+ },
4977
+
4978
+ # Specific subdomains take precedence
4979
+ "www.example.com" => {
4980
+ "/" => {
4981
+ get: {
4982
+ to: ->(req, res, params) {
4983
+ res.text("Main marketing site")
4984
+ }
4985
+ }
4986
+ }
4987
+ },
4988
+
4989
+ "api.example.com" => {
4990
+ "/" => {
4991
+ get: {
4992
+ to: ->(req, res, params) {
4993
+ res.text("API documentation")
4994
+ }
4995
+ }
4996
+ }
4997
+ }
4998
+ })