utopia 2.32.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/bake/utopia/environment.rb +6 -6
  4. data/bake/utopia/server.rb +4 -1
  5. data/bake/utopia/site.rb +14 -14
  6. data/bake/utopia/static.rb +26 -13
  7. data/bake/utopia.rb +1 -1
  8. data/context/getting-started.md +7 -5
  9. data/context/index.yaml +1 -2
  10. data/context/middleware.md +42 -13
  11. data/lib/utopia/application.rb +83 -0
  12. data/lib/utopia/content/builder.rb +25 -0
  13. data/lib/utopia/content/document.rb +64 -6
  14. data/lib/utopia/content/link.rb +50 -5
  15. data/lib/utopia/content/links.rb +44 -7
  16. data/lib/utopia/content/markup.rb +74 -0
  17. data/lib/utopia/content/middleware.rb +48 -20
  18. data/lib/utopia/content/namespace.rb +10 -1
  19. data/lib/utopia/content/node.rb +66 -4
  20. data/lib/utopia/content/response.rb +12 -3
  21. data/lib/utopia/content.rb +3 -0
  22. data/lib/utopia/controller/actions.md +4 -4
  23. data/lib/utopia/controller/actions.rb +51 -3
  24. data/lib/utopia/controller/base.rb +72 -29
  25. data/lib/utopia/controller/middleware.rb +20 -15
  26. data/lib/utopia/controller/respond.rb +36 -32
  27. data/lib/utopia/controller/responder.rb +106 -45
  28. data/lib/utopia/controller/result.rb +11 -0
  29. data/lib/utopia/controller/rewrite.rb +39 -0
  30. data/lib/utopia/controller/variables.rb +15 -3
  31. data/lib/utopia/controller.rb +2 -0
  32. data/lib/utopia/exceptions/handler.rb +20 -11
  33. data/lib/utopia/exceptions/mailer.rb +56 -51
  34. data/lib/utopia/extensions/array_split.rb +6 -0
  35. data/lib/utopia/extensions/date_comparisons.rb +6 -0
  36. data/lib/utopia/http.rb +11 -48
  37. data/lib/utopia/import_map.rb +19 -11
  38. data/lib/utopia/localization/locales.rb +60 -0
  39. data/lib/utopia/localization/middleware.rb +107 -73
  40. data/lib/utopia/localization/preferences.rb +76 -0
  41. data/lib/utopia/localization/resolver.rb +47 -0
  42. data/lib/utopia/localization.rb +6 -0
  43. data/lib/utopia/middleware.rb +3 -4
  44. data/lib/utopia/path/matcher.rb +15 -0
  45. data/lib/utopia/path.rb +146 -10
  46. data/lib/utopia/redirection/client_redirect.rb +87 -0
  47. data/lib/utopia/redirection/directory_index.rb +41 -0
  48. data/lib/utopia/redirection/errors.rb +78 -0
  49. data/lib/utopia/redirection/moved.rb +52 -0
  50. data/lib/utopia/redirection/request_failure.rb +26 -0
  51. data/lib/utopia/redirection/rewrite.rb +42 -0
  52. data/lib/utopia/redirection.rb +7 -165
  53. data/lib/utopia/request.rb +202 -0
  54. data/lib/utopia/response.rb +73 -0
  55. data/lib/utopia/session/lazy_hash.rb +27 -1
  56. data/lib/utopia/session/middleware.rb +103 -30
  57. data/lib/utopia/session/serialization.rb +8 -0
  58. data/lib/utopia/session.rb +5 -0
  59. data/lib/utopia/setup.rb +23 -3
  60. data/lib/utopia/shell.rb +28 -7
  61. data/lib/utopia/static/local_file.rb +84 -61
  62. data/lib/utopia/static/middleware.rb +65 -33
  63. data/lib/utopia/static/mime_types.rb +38 -29
  64. data/lib/utopia/static.rb +2 -0
  65. data/lib/utopia/version.rb +3 -2
  66. data/lib/utopia.rb +1 -1
  67. data/license.md +1 -1
  68. data/readme.md +33 -4
  69. data/releases.md +15 -0
  70. data/setup/site/bake.rb +1 -1
  71. data/setup/site/config/application.rb +51 -0
  72. data/setup/site/config/serve.rb +8 -0
  73. data/setup/site/falcon.rb +17 -4
  74. data/setup/site/fixtures/website.rb +27 -11
  75. data/setup/site/gems.rb +1 -3
  76. data/setup/site/lib/readme.txt +1 -1
  77. data/setup/site/pages/welcome/index.xnode +3 -3
  78. data/setup/site/readme.md +0 -3
  79. data/setup/site/test/website.rb +2 -2
  80. data.tar.gz.sig +0 -0
  81. metadata +60 -34
  82. metadata.gz.sig +0 -0
  83. data/lib/utopia/localization/wrapper.rb +0 -52
  84. data/setup/site/Guardfile +0 -12
  85. data/setup/site/config.ru +0 -49
data/lib/utopia/path.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2009-2025, by Samuel Williams.
5
5
 
6
+ require "protocol/url/path"
7
+
6
8
  module Utopia
7
9
  # Represents a path as an array of path components. Useful for efficient URL manipulation.
8
10
  class Path
@@ -10,12 +12,16 @@ module Utopia
10
12
 
11
13
  SEPARATOR = "/"
12
14
 
15
+ # Initialize a path from its individual components.
16
+ # @parameter components [Array(String)] The path components, including empty components that denote leading or trailing separators.
13
17
  def initialize(components = [])
14
18
  @components = components
15
19
  end
16
20
 
17
21
  attr_accessor :components
18
22
 
23
+ # Freeze this object and its internal state.
24
+ # @returns [self] This object.
19
25
  def freeze
20
26
  return self if frozen?
21
27
 
@@ -24,20 +30,30 @@ module Utopia
24
30
  super
25
31
  end
26
32
 
33
+ # Check whether this path has no components.
34
+ # @returns [Boolean] Whether the path has no components.
27
35
  def empty?
28
36
  @components.empty?
29
37
  end
30
38
 
39
+ # Construct the root path.
40
+ # @returns [Path] The root path.
31
41
  def self.root
32
42
  self.new([""])
33
43
  end
34
44
 
35
- # Returns the length of the prefix which is shared by two strings.
45
+ # Compute the number of leading components shared by two sequences.
46
+ # @parameter a [Array] The first sequence.
47
+ # @parameter b [Array] The second sequence.
48
+ # @returns [Integer | Nil] The shared prefix length, or `nil` when every component in the shorter sequence matches.
36
49
  def self.prefix_length(a, b)
37
50
  [a.size, b.size].min.times{|i| return i if a[i] != b[i]}
38
51
  end
39
52
 
40
- # Return the shortest relative path to get to path from root:
53
+ # Compute the shortest relative path from the containing directory of `root` to `path`.
54
+ # @parameter path [Path | String | Array] The destination path.
55
+ # @parameter root [Path | String | Array] The source path.
56
+ # @returns [Path] The shortest relative path.
41
57
  def self.shortest_path(path, root)
42
58
  path = self.create(path)
43
59
  root = self.create(root).dirname
@@ -51,21 +67,32 @@ module Utopia
51
67
  return self.create([".."] * up + path.components[i..-1])
52
68
  end
53
69
 
70
+ # Compute the shortest relative path from the containing directory of `root` to this path.
71
+ # @parameter root [Path | String | Array] The source path.
72
+ # @returns [Path] The shortest relative path.
54
73
  def shortest_path(root)
55
74
  self.class.shortest_path(self, root)
56
75
  end
57
76
 
58
- # Converts '+' into whitespace and hex encoded characters into their equivalent characters.
77
+ # Decode URL-encoded path content, converting `+` to whitespace and percent-encoded bytes to their corresponding characters.
78
+ # @parameter string [String] The encoded content.
79
+ # @returns [String] The decoded content.
59
80
  def self.unescape(string)
60
81
  string.tr("+", " ").gsub(/((?:%[0-9a-fA-F]{2})+)/n) do
61
82
  [$1.delete("%")].pack("H*")
62
83
  end
63
84
  end
64
85
 
86
+ # Coerce the given value into a path.
87
+ # @parameter path [Utopia::Path | String] The path.
88
+ # @returns [Path | Nil] The coerced path.
65
89
  def self.[] path
66
90
  self.create(path)
67
91
  end
68
92
 
93
+ # Convert a path value into an array of components.
94
+ # @parameter path [Utopia::Path | String] The path.
95
+ # @returns [Array] The path components.
69
96
  def self.split(path)
70
97
  case path
71
98
  when Path
@@ -79,19 +106,30 @@ module Utopia
79
106
  end
80
107
  end
81
108
 
82
- # This constructor takes a string and generates a relative path as efficiently as possible. This is a direct entry point for all controller invocations so it's designed to suit the requirements of that function.
109
+ # Construct a path from URL-encoded text. This is an optimized direct entry point used by controller invocations.
110
+ # @parameter string [String] The encoded path.
111
+ # @returns [Path] The decoded path.
83
112
  def self.from_string(string)
84
113
  self.new(unescape(string).split(SEPARATOR, -1))
85
114
  end
86
115
 
116
+ # Load a path from its serialized form.
117
+ # @parameter value [String | Nil] The serialized path.
118
+ # @returns [Path | Nil] The loaded path.
87
119
  def self.load(value)
88
120
  from_string(value) if value
89
121
  end
90
122
 
123
+ # Serialize a path.
124
+ # @parameter instance [Path | Nil] The path to serialize.
125
+ # @returns [String | Nil] The serialized path.
91
126
  def self.dump(instance)
92
127
  instance.to_s if instance
93
128
  end
94
129
 
130
+ # Coerce a value into a path.
131
+ # @parameter path [Path | Array | String | Object | Nil] The value to coerce.
132
+ # @returns [Path | Nil] The coerced path.
95
133
  def self.create(path)
96
134
  case path
97
135
  when Path
@@ -107,22 +145,34 @@ module Utopia
107
145
  end
108
146
  end
109
147
 
148
+ # Replace this path's components with a copy of another path's components.
149
+ # @parameter other_path [Path] The replacement path.
150
+ # @returns [Array(String)] The copied components.
110
151
  def replace(other_path)
111
152
  @components = other_path.components.dup
112
153
  end
113
154
 
155
+ # Check whether this collection includes the given value.
156
+ # @parameter arguments [Array] The arguments.
157
+ # @returns [Boolean] Whether any component matches the given argument.
114
158
  def include?(*arguments)
115
159
  @components.include?(*arguments)
116
160
  end
117
161
 
162
+ # Check whether this path denotes a directory.
163
+ # @returns [Boolean] Whether the path ends with a directory separator.
118
164
  def directory?
119
165
  return @components.last == ""
120
166
  end
121
167
 
168
+ # Check whether this path denotes a file.
169
+ # @returns [Boolean] Whether the path ends with a file component.
122
170
  def file?
123
171
  return @components.last != ""
124
172
  end
125
173
 
174
+ # Convert this path to a directory path.
175
+ # @returns [Path] A directory path.
126
176
  def to_directory
127
177
  if directory?
128
178
  return self
@@ -131,14 +181,20 @@ module Utopia
131
181
  end
132
182
  end
133
183
 
184
+ # Check whether this path is relative.
185
+ # @returns [Boolean] Whether the path is relative.
134
186
  def relative?
135
187
  @components.first != ""
136
188
  end
137
189
 
190
+ # Check whether this path is absolute.
191
+ # @returns [Boolean] Whether the path is absolute.
138
192
  def absolute?
139
193
  @components.first == ""
140
194
  end
141
195
 
196
+ # Convert this path to an absolute path.
197
+ # @returns [Path] An absolute path.
142
198
  def to_absolute
143
199
  if absolute?
144
200
  return self
@@ -147,10 +203,14 @@ module Utopia
147
203
  end
148
204
  end
149
205
 
206
+ # Remove the first component when this path is relative.
207
+ # @returns [String | Nil] The removed component, or `nil` when the path is absolute.
150
208
  def to_relative!
151
209
  @components.shift if relative?
152
210
  end
153
211
 
212
+ # Convert this object to a string.
213
+ # @returns [String] The resulting string.
154
214
  def to_str
155
215
  if @components == [""]
156
216
  SEPARATOR
@@ -161,11 +221,28 @@ module Utopia
161
221
 
162
222
  alias to_s to_str
163
223
 
224
+ # Encode this application path as a URL path.
225
+ # @returns [Protocol::URL::Path] The encoded URL path.
226
+ def to_url_path
227
+ # Preserve Utopia's compact representation of the absolute root:
228
+ if @components == [""]
229
+ return Protocol::URL::Path[SEPARATOR]
230
+ end
231
+
232
+ return Protocol::URL::Path.for(
233
+ @components,
234
+ encoding: Protocol::URL::Encoding::System,
235
+ )
236
+ end
237
+
238
+ # Convert this path to an array of components.
239
+ # @returns [Array] The resulting values.
164
240
  def to_a
165
241
  @components
166
242
  end
167
243
 
168
244
  # @parameter other [Array(String)] The path components to append.
245
+ # @returns [Path] The joined and simplified path.
169
246
  def join(other)
170
247
  # Check whether other is an absolute path:
171
248
  if other.first == ""
@@ -175,10 +252,16 @@ module Utopia
175
252
  end
176
253
  end
177
254
 
255
+ # Resolve this path relative to a root path.
256
+ # @parameter root [Path] The root path.
257
+ # @returns [Path] The resolved path.
178
258
  def expand(root)
179
259
  root + self
180
260
  end
181
261
 
262
+ # Append path components and return the resulting path.
263
+ # @parameter other [Path | Array | String | Object] The value to append.
264
+ # @returns [Path] The joined and simplified path, or `other` when it is an absolute path.
182
265
  def +(other)
183
266
  if other.kind_of? Path
184
267
  if other.absolute?
@@ -195,6 +278,9 @@ module Utopia
195
278
  end
196
279
  end
197
280
 
281
+ # Prepend a path to this path.
282
+ # @parameter arguments [Array] The arguments accepted by {.create}.
283
+ # @returns [Path] The prefixed path.
198
284
  def with_prefix(*arguments)
199
285
  self.class.create(*arguments) + self
200
286
  end
@@ -202,6 +288,8 @@ module Utopia
202
288
  # Computes the difference of the path.
203
289
  # /a/b/c - /a/b -> c
204
290
  # a/b/c - a/b -> c
291
+ # @parameter other [Path] The prefix path to remove.
292
+ # @returns [Path] The remaining path.
205
293
  def -(other)
206
294
  i = 0
207
295
 
@@ -214,6 +302,8 @@ module Utopia
214
302
  return self.class.new(@components[i,@components.size])
215
303
  end
216
304
 
305
+ # Normalize current-directory, parent-directory, and repeated-separator components.
306
+ # @returns [Path] The normalized path.
217
307
  def simplify
218
308
  components = []
219
309
 
@@ -245,7 +335,8 @@ module Utopia
245
335
  return self.class.new(components)
246
336
  end
247
337
 
248
- # Returns the first path component.
338
+ # Return the first path component, excluding the root marker.
339
+ # @returns [String | Nil] The first component.
249
340
  def first
250
341
  if absolute?
251
342
  @components[1]
@@ -254,7 +345,8 @@ module Utopia
254
345
  end
255
346
  end
256
347
 
257
- # Returns the last path component.
348
+ # Return the last path component, excluding the root marker.
349
+ # @returns [String | Nil] The last component.
258
350
  def last
259
351
  if @components != [""]
260
352
  @components.last
@@ -263,7 +355,8 @@ module Utopia
263
355
 
264
356
  alias last? file?
265
357
 
266
- # Pops the last path component.
358
+ # Remove the last path component without converting the root path to a relative path.
359
+ # @returns [String | Nil] The removed component.
267
360
  def pop
268
361
  # We don't want to convert an absolute path to a relative path.
269
362
  if @components != [""]
@@ -271,30 +364,39 @@ module Utopia
271
364
  end
272
365
  end
273
366
 
274
- # @return [String] the last path component without any file extension.
367
+ # @returns [String] The last path component without its file extension.
275
368
  def basename
276
369
  basename, _ = @components.last.split(".", 2)
277
370
 
278
371
  return basename || ""
279
372
  end
280
373
 
281
- # @return [String] the last path component's file extension.
374
+ # @returns [String | Nil] The last path component's file extension.
282
375
  def extension
283
376
  _, extension = @components.last.split(".", 2)
284
377
 
285
378
  return extension
286
379
  end
287
380
 
381
+ # Remove trailing path components.
382
+ # @parameter count [Integer] The number of components.
383
+ # @returns [Path] The containing path.
288
384
  def dirname(count = 1)
289
385
  path = self.class.new(@components[0...-count])
290
386
 
291
387
  return absolute? ? path.to_absolute : path
292
388
  end
293
389
 
390
+ # Format this path using a local filesystem separator.
391
+ # @parameter separator [String] The component separator.
392
+ # @returns [String] The local path.
294
393
  def local_path(separator = File::SEPARATOR)
295
394
  @components.join(separator)
296
395
  end
297
396
 
397
+ # Enumerate paths from the first component down to this path.
398
+ # @yields {|path| ...} Each successively longer path.
399
+ # @returns [Enumerator | Array] An enumerator when no block is given, otherwise the component array.
298
400
  def descend(&block)
299
401
  return to_enum(:descend) unless block_given?
300
402
 
@@ -307,6 +409,9 @@ module Utopia
307
409
  end
308
410
  end
309
411
 
412
+ # Enumerate paths from this path up to its first component.
413
+ # @yields {|path| ...} Each successively shorter path.
414
+ # @returns [Enumerator | Nil] An enumerator when no block is given.
310
415
  def ascend(&block)
311
416
  return to_enum(:ascend) unless block_given?
312
417
 
@@ -319,6 +424,9 @@ module Utopia
319
424
  end
320
425
  end
321
426
 
427
+ # Split this path around a component or component index.
428
+ # @parameter at [Integer | String] The component index or value at which to split.
429
+ # @returns [Array(Path, Path) | Nil] The paths before and after the matched component, or `nil` when it is not found.
322
430
  def split(at)
323
431
  if at.kind_of?(String)
324
432
  at = @components.index(at)
@@ -331,22 +439,35 @@ module Utopia
331
439
  end
332
440
  end
333
441
 
442
+ # Copy this path and its component array.
443
+ # @returns [Path] The copied path.
334
444
  def dup
335
445
  return Path.new(components.dup)
336
446
  end
337
447
 
448
+ # Compare this object with another object.
449
+ # @parameter other [Object] The object to compare.
450
+ # @returns [Integer | Nil] The comparison result.
338
451
  def <=> other
339
452
  @components <=> other.components
340
453
  end
341
454
 
455
+ # Check whether this object is equivalent to another object.
456
+ # @parameter other [Object] The object to compare.
457
+ # @returns [Boolean] Whether the paths have the same class and components.
342
458
  def eql? other
343
459
  self.class.eql?(other.class) and @components.eql?(other.components)
344
460
  end
345
461
 
462
+ # Compute the hash value for this object.
463
+ # @returns [Integer] The resulting integer.
346
464
  def hash
347
465
  @components.hash
348
466
  end
349
467
 
468
+ # Compare this object with another object.
469
+ # @parameter other [Object] The object to compare.
470
+ # @returns [Boolean] Whether the path is equivalent to the given string, array, or path.
350
471
  def == other
351
472
  return false unless other
352
473
 
@@ -357,6 +478,9 @@ module Utopia
357
478
  end
358
479
  end
359
480
 
481
+ # Check whether this path starts with the given path.
482
+ # @parameter other [Path] The possible prefix.
483
+ # @returns [Boolean] Whether this path starts with all components of `other`.
360
484
  def start_with? other
361
485
  other.components.each_with_index do |part, index|
362
486
  return false if @components[index] != part
@@ -365,15 +489,24 @@ module Utopia
365
489
  return true
366
490
  end
367
491
 
492
+ # Fetch one or more path components, excluding root and directory markers from indexing.
493
+ # @parameter index [Integer | Range] The component index or range.
494
+ # @returns [String | Array(String) | Nil] The selected component or components.
368
495
  def [] index
369
496
  return @components[component_offset(index)]
370
497
  end
371
498
 
372
- # Replaces a named component, indexing as per
499
+ # Replace one or more path components using the same root- and directory-marker-aware indexing as {#[]}.
500
+ # @parameter index [Integer | Range] The component index or range.
501
+ # @parameter value [String | Array(String)] The replacement component or components.
502
+ # @returns [String | Array(String)] The assigned value.
373
503
  def []= index, value
374
504
  return @components[component_offset(index)] = value
375
505
  end
376
506
 
507
+ # Delete a path component, excluding root and directory markers from indexing.
508
+ # @parameter index [Integer] The component index.
509
+ # @returns [String | Nil] The deleted component.
377
510
  def delete_at(index)
378
511
  @components.delete_at(component_offset(index))
379
512
  end
@@ -400,6 +533,9 @@ module Utopia
400
533
  end
401
534
  end
402
535
 
536
+ # Coerce a value into a {Path}.
537
+ # @parameter path [Utopia::Path | String] The path.
538
+ # @returns [Path | Nil] The coerced path.
403
539
  def self.Path(path)
404
540
  Path.create(path)
405
541
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../middleware"
7
+ require_relative "../response"
8
+
9
+ module Utopia
10
+ # A middleware which assists with redirecting from one path to another.
11
+ module Redirection
12
+ # We cache 301 redirects for 24 hours.
13
+ MAX_AGE = 3600*24
14
+
15
+ # A basic client-side redirect.
16
+ class ClientRedirect < Protocol::HTTP::Middleware
17
+ # Initialize client-side redirection behavior.
18
+ # @parameter app [Interface(:call)] The downstream application.
19
+ # @parameter status [Integer] The status.
20
+ # @parameter max_age [Integer] The maximum cache age in seconds.
21
+ def initialize(app, status: 307, max_age: MAX_AGE)
22
+ super(app)
23
+
24
+ @status = status
25
+ @max_age = max_age
26
+ end
27
+
28
+ # Freeze this object and its internal state.
29
+ # @returns [self] This object.
30
+ def freeze
31
+ return self if frozen?
32
+
33
+ @status.freeze
34
+ @max_age.freeze
35
+
36
+ super
37
+ end
38
+
39
+ attr :status
40
+ attr :max_age
41
+
42
+ # Build the cache control header value.
43
+ # @returns [String] The cache-control value.
44
+ def cache_control
45
+ # http://jacquesmattheij.com/301-redirects-a-dangerous-one-way-street
46
+ "max-age=#{self.max_age}"
47
+ end
48
+
49
+ # Build headers for a client redirect.
50
+ # @parameter location [String] The redirect location.
51
+ # @returns [Hash(String, String)] The redirect headers.
52
+ def make_headers(location)
53
+ {
54
+ HTTP::LOCATION => location,
55
+ HTTP::CACHE_CONTROL => self.cache_control
56
+ }
57
+ end
58
+
59
+ # Build a redirect response for the given location.
60
+ # @parameter location [String] The redirect location.
61
+ # @returns [Protocol::HTTP::Response] The redirect response.
62
+ def redirect(location)
63
+ return Response[self.status, self.make_headers(location), []]
64
+ end
65
+
66
+ # Resolve a normalized request path to a redirect response.
67
+ # @parameter path [String] The normalized request path.
68
+ # @returns [Protocol::HTTP::Response | false] The redirect response, or `false` by default.
69
+ def [] path
70
+ false
71
+ end
72
+
73
+ # Redirect a normalized request path when it matches, otherwise invoke the application.
74
+ # @parameter request [Utopia::Request] The request.
75
+ # @returns [Protocol::HTTP::Response] The redirect or downstream response.
76
+ def call(request)
77
+ path = request.url.path.encoded
78
+
79
+ if redirection = self[path]
80
+ return redirection
81
+ end
82
+
83
+ return @delegate.call(request)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "client_redirect"
7
+
8
+ module Utopia
9
+ module Redirection
10
+ # Redirect urls that end with a `/`, e.g. directories.
11
+ class DirectoryIndex < ClientRedirect
12
+ # Initialize directory-index redirection.
13
+ # @parameter app [Interface(:call)] The downstream application.
14
+ # @parameter index [Integer] The index.
15
+ def initialize(app, index: "index")
16
+ @index = index
17
+
18
+ super(app)
19
+ end
20
+
21
+ # Freeze this object and its internal state.
22
+ # @returns [self] This object.
23
+ def freeze
24
+ return self if frozen?
25
+
26
+ @index.freeze
27
+
28
+ return super
29
+ end
30
+
31
+ # Redirect a directory path to its index path.
32
+ # @parameter path [String] The normalized request path.
33
+ # @returns [Protocol::HTTP::Response | Nil] The redirect response when the path ends with `/`.
34
+ def [] path
35
+ if path.end_with?("/")
36
+ return redirect(path + @index)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../middleware"
7
+ require_relative "../request"
8
+ require_relative "../response"
9
+ require_relative "request_failure"
10
+
11
+ module Utopia
12
+ module Redirection
13
+ # A middleware which performs internal redirects based on error status codes.
14
+ #
15
+ # Place this middleware after client-visible redirection middleware in the
16
+ # application configuration. Internal error-document requests invoke the
17
+ # delegate directly, bypassing middleware configured before this one.
18
+ class Errors < Protocol::HTTP::Middleware
19
+ # @param codes [Hash<Integer,String>] The redirection path for a given error code.
20
+ def initialize(app, codes = {})
21
+ super(app)
22
+
23
+ @codes = codes
24
+ end
25
+
26
+ # Freeze this object and its internal state.
27
+ # @returns [self] This object.
28
+ def freeze
29
+ return self if frozen?
30
+
31
+ @codes.freeze
32
+
33
+ super
34
+ end
35
+
36
+ # Check whether the response status requires error handling.
37
+ # @parameter response [Protocol::HTTP::Response] The response.
38
+ # @returns [Boolean] Whether the response is an error without handler-provided headers.
39
+ def unhandled_error?(response)
40
+ response.status >= 400 && response.headers.empty?
41
+ end
42
+
43
+ # Replace an unhandled error response with its configured error document.
44
+ # @parameter request [Utopia::Request] The request.
45
+ # @returns [Protocol::HTTP::Response] The original or error-document response.
46
+ # @raises [RequestFailure] If the configured error document also fails.
47
+ def call(request)
48
+ response = Response.wrap(@delegate.call(request))
49
+
50
+ if unhandled_error?(response) && location = @codes[response.status]
51
+ resource_status = response.status
52
+
53
+ # The original response is replaced by the configured error document:
54
+ response.close
55
+
56
+ error_request = request.with(method: "GET", path: location)
57
+
58
+ error_response = Response.wrap(@delegate.call(error_request))
59
+
60
+ if error_response.status >= 400
61
+ error = RequestFailure.new(request.url.path.encoded, resource_status, location, error_response.status)
62
+
63
+ # The failed error document will not be returned to the server:
64
+ error_response.close(error)
65
+
66
+ raise error
67
+ else
68
+ # Feed the error code back with the error document:
69
+ error_response.status = resource_status
70
+ return error_response
71
+ end
72
+ else
73
+ return response
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "client_redirect"
7
+
8
+ module Utopia
9
+ module Redirection
10
+ # Rewrite requests that match the given pattern to a new prefix.
11
+ class Moved < ClientRedirect
12
+ # Initialize prefix redirection behavior.
13
+ # @parameter app [Interface(:call)] The downstream application.
14
+ # @parameter pattern [Regexp] The path pattern.
15
+ # @parameter prefix [String] The prefix.
16
+ # @parameter status [Integer] The status.
17
+ # @parameter flatten [bool] Whether to flatten the rewritten path.
18
+ def initialize(app, pattern, prefix, status: 301, flatten: false)
19
+ @pattern = pattern
20
+ @prefix = prefix
21
+ @flatten = flatten
22
+
23
+ super(app, status: status)
24
+ end
25
+
26
+ # Freeze this object and its internal state.
27
+ # @returns [self] This object.
28
+ def freeze
29
+ return self if frozen?
30
+
31
+ @pattern.freeze
32
+ @prefix.freeze
33
+ @flatten.freeze
34
+
35
+ return super
36
+ end
37
+
38
+ # Redirect a matching path to the configured prefix.
39
+ # @parameter path [String] The normalized request path.
40
+ # @returns [Protocol::HTTP::Response | Nil] The redirect response when the pattern matches.
41
+ def [] path
42
+ if path.start_with?(@pattern)
43
+ if @flatten
44
+ return redirect(@prefix)
45
+ else
46
+ return redirect(path.sub(@pattern, @prefix))
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end