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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +251 -0
- data/README.md +18 -0
- data/docs/ADAPTERS.md +478 -0
- data/docs/ARCHITECTURE.md +222 -0
- data/docs/CONTENT.md +967 -0
- data/docs/PERFORMANCE.md +492 -0
- data/docs/PLUGIN_DEVELOPMENT.md +688 -0
- data/docs/USAGE.md +4998 -0
- data/docs/plugins/API_KEY_AUTH.md +232 -0
- data/docs/plugins/BASIC_AUTH.md +582 -0
- data/docs/plugins/BEARER_AUTH.md +394 -0
- data/docs/plugins/CACHE.md +369 -0
- data/docs/plugins/COMPRESSION.md +216 -0
- data/docs/plugins/COOKIES.md +30 -0
- data/docs/plugins/CORS.md +283 -0
- data/docs/plugins/CSRF.md +751 -0
- data/docs/plugins/ETAG.md +308 -0
- data/docs/plugins/FORM_PARSER.md +193 -0
- data/docs/plugins/HEALTH_CHECK.md +469 -0
- data/docs/plugins/JSON.md +291 -0
- data/docs/plugins/MULTIPART.md +427 -0
- data/docs/plugins/RATE_LIMITER.md +368 -0
- data/docs/plugins/REQUEST_ID.md +369 -0
- data/docs/plugins/REQUEST_LOGGER.md +151 -0
- data/docs/plugins/SECURITY.md +193 -0
- data/docs/plugins/SESSION.md +98 -0
- data/lib/aris/adapters/rack/adapter.rb +17 -2
- data/lib/aris/adapters/rack/request.rb +29 -11
- data/lib/aris/plugins/basic_auth.rb +3 -1
- data/lib/aris/plugins/cookies.rb +4 -32
- data/lib/aris/plugins/cors.rb +8 -1
- data/lib/aris/plugins/csrf.rb +63 -22
- data/lib/aris/plugins/flash.rb +3 -1
- data/lib/aris/plugins/form_parser.rb +52 -31
- data/lib/aris/plugins/multipart.rb +22 -2
- data/lib/aris/plugins/request_logger.rb +8 -1
- data/lib/aris/plugins/security_headers.rb +8 -1
- data/lib/aris/plugins/session.rb +150 -99
- data/lib/aris/response_helpers.rb +41 -0
- data/lib/aris/version.rb +2 -2
- 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
|
+
})
|