Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Utopia includes a redirection middleware to redirect all root-level requests to

Application = Utopia::Application.build do
use Utopia::Redirection::Rewrite,
"/" => "/welcome/index"
{"/" => "/welcome/index"}
end
```

Expand Down
13 changes: 10 additions & 3 deletions context/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,25 @@ use Utopia::Static,

## Redirection

The {ruby Utopia::Redirection} middleware is used for redirecting requests based on patterns and status codes.
The redirection middleware is used for redirecting requests based on paths.

~~~ ruby
# String (fast hash lookup) rewriting:
use Utopia::Redirection::Rewrite,
'/' => '/welcome/index'
{'/' => '/welcome/index'}

# Redirect directories (e.g. /) to an index file (e.g. /index):
use Utopia::Redirection::DirectoryIndex,
index: 'index.html'

# Redirect (error) status codes to actual pages:
# Redirect matching path prefixes:
use Utopia::Redirection::Moved,
'/old/', '/new/'
~~~

The {ruby Utopia::Redirection::Errors} middleware maps unhandled error responses to internal error documents. It retains the original response status and does not issue a client-visible redirect:

~~~ ruby
use Utopia::Redirection::Errors,
404 => '/errors/file-not-found'
~~~
Expand Down
2 changes: 1 addition & 1 deletion guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Utopia includes a redirection middleware to redirect all root-level requests to

Application = Utopia::Application.build do
use Utopia::Redirection::Rewrite,
"/" => "/welcome/index"
{"/" => "/welcome/index"}
end
```

Expand Down
13 changes: 10 additions & 3 deletions guides/middleware/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,25 @@ use Utopia::Static,

## Redirection

The {ruby Utopia::Redirection} middleware is used for redirecting requests based on patterns and status codes.
The redirection middleware is used for redirecting requests based on paths.

~~~ ruby
# String (fast hash lookup) rewriting:
use Utopia::Redirection::Rewrite,
'/' => '/welcome/index'
{'/' => '/welcome/index'}

# Redirect directories (e.g. /) to an index file (e.g. /index):
use Utopia::Redirection::DirectoryIndex,
index: 'index.html'

# Redirect (error) status codes to actual pages:
# Redirect matching path prefixes:
use Utopia::Redirection::Moved,
'/old/', '/new/'
~~~

The {ruby Utopia::Redirection::Errors} middleware maps unhandled error responses to internal error documents. It retains the original response status and does not issue a client-visible redirect:

~~~ ruby
use Utopia::Redirection::Errors,
404 => '/errors/file-not-found'
~~~
Expand Down
19 changes: 14 additions & 5 deletions lib/utopia/content/links.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative "link"

require "concurrent/map"
require "protocol/url/path"

module Utopia
module Content
Expand Down Expand Up @@ -128,7 +129,10 @@ def metadata(path)
# @parameter path [Utopia::Path | String] The path.
# @returns [Resolver] The link resolver.
def links(path)
@links_cache.fetch_or_store(path.to_s) do
path = Path.create(path)
key = path.dup.freeze

@links_cache.fetch_or_store(key) do
load_links(path)
end
end
Expand Down Expand Up @@ -170,13 +174,18 @@ def initialize(links, top = Path.root)

@top = top

# top.components.first == '', but this isn't a problem here.
@path = File.join(links.root, top.components)

@ordered = []
@named = {}

if File.directory?(@path)
begin
# Preserve URL segment boundaries when mapping the route to the filesystem:
url_path = Protocol::URL::Path.for(top.components)
@path = url_path.local_path(links.root)
rescue ArgumentError
@path = nil
end

if @path && File.directory?(@path)
@metadata = links.metadata(@path)

load_links(@metadata.dup) do |link|
Expand Down
22 changes: 16 additions & 6 deletions lib/utopia/content/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,19 @@ def respond(link, request, localization: request.localization)
# @parameter request [Utopia::Request] The request.
# @returns [Protocol::HTTP::Response] The content, redirect, or downstream response.
def call(request)
path = Path.create(request.path_info)
path = Path.create(request.url.path)

# Check if the request is to a non-specific index. This only works for requests with a given name:
basename = path.basename
directory_path = File.join(@root, path.dirname.components, basename)
directory_path = local_path(path)

# If the request for /foo/bar is actually a directory, rewrite it to /foo/bar/index:
if File.directory? directory_path
index_path = [basename, INDEX]

return Utopia::Response[307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []]
if directory_path
if File.directory?(directory_path)
index_path = [basename, INDEX]

return Utopia::Response[307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []]
end
end

response = resolve_localized(request) do |localization|
Expand All @@ -153,6 +155,14 @@ def call(request)

private

# Resolve a decoded content path without losing its URL segment boundaries:
def local_path(path)
url_path = Protocol::URL::Path.for(path.components)
return url_path.local_path(@root)
rescue ArgumentError
return nil
end

def lookup_content(name, parent_path)
if String === name && name.index("/")
name = Path.create(name)
Expand Down
19 changes: 14 additions & 5 deletions lib/utopia/controller/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
require_relative "actions"

require "concurrent/map"
require "protocol/url/path"

module Utopia
# A middleware which loads controller classes and invokes functionality based on the requested path.
Expand Down Expand Up @@ -49,14 +50,22 @@ def freeze

# Fetch the controller for the given relative path. May be cached.
def lookup_controller(path)
@controller_cache.fetch_or_store(path.to_s) do
key = path.dup.freeze

@controller_cache.fetch_or_store(key) do
load_controller_file(path)
end
end

# Loads the controller file for the given relative url_path.
def load_controller_file(uri_path)
base_path = File.join(@root, uri_path.components)
begin
# Preserve URL segment boundaries when mapping the route to the filesystem:
url_path = Protocol::URL::Path.for(uri_path.components)
base_path = url_path.local_path(@root)
rescue ArgumentError
return nil
end

controller_path = File.join(base_path, CONTROLLER_RB)
# puts "load_controller_file(#{path.inspect}) => #{controller_path}"
Expand Down Expand Up @@ -86,7 +95,7 @@ def load_controller_file(uri_path)

# Invoke the controller layer for a given request. The request path may be rewritten.
def invoke_controllers(request)
request_path = Path.from_string(request.path_info)
request_path = Path[request.url.path]

# The request path must be absolute. We could handle this internally but it is probably better for this to be an error:
raise ArgumentError.new("Invalid request path #{request_path}") unless request_path.absolute?
Expand Down Expand Up @@ -114,8 +123,8 @@ def invoke_controllers(request)
end
end

# Controllers can directly modify relative_path, which is copied into controller_path. The controllers may have rewriten the path so we update the path info:
request.path_info = controller_path.to_s
# Controllers can directly modify the remaining path, so update the current request URL:
request.url = request.url.with(path: Protocol::URL::Path.for(controller_path.components))

# No controller gave a useful result:
return nil
Expand Down
2 changes: 1 addition & 1 deletion lib/utopia/exceptions/handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def call(request)
# We do an internal redirection to the error location:
error_request = request.with(
method: "GET",
path_info: @location
url: request.url.with(path: @location)
)
error_request.exception = exception

Expand Down
2 changes: 1 addition & 1 deletion lib/utopia/exceptions/mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def call(request)
:referrer,
:path,
:request_path,
:path_info,
:url,
:query,
:user_agent,
]
Expand Down
25 changes: 25 additions & 0 deletions lib/utopia/invalid_path_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "protocol/http/error"

module Utopia
# Raised when an external request path cannot be normalized safely.
class InvalidPathError < Protocol::HTTP::Error
include Protocol::HTTP::BadRequest

# Initialize the invalid path error.
# @parameter path [String] The invalid request path.
# @parameter message [String] The reason the path is invalid.
def initialize(path, message)
@path = path

super("Invalid request path #{path.inspect}: #{message}")
end

# The invalid request path.
attr :path
end
end
9 changes: 5 additions & 4 deletions lib/utopia/localization/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,14 @@ def host_preferred_locales(request)
# @parameter request [Utopia::Request] The application request.
# @returns [Array(Utopia::Request, String | Nil)] The request and extracted locale.
def extract_path_locale(request)
path = Path[request.path_info]
path = Path[request.url.path]

if request_locale = @all_locales.patterns[path.first]
# Remove the localization prefix:
path.delete_at(0)

return request.with(path_info: path.to_s), request_locale
url_path = Protocol::URL::Path.for(path.components)
return request.with(url: request.url.with(path: url_path)), request_locale
else
return request, nil
end
Expand Down Expand Up @@ -147,8 +148,8 @@ def browser_preferred_locales(request)
# @returns [Boolean] Whether the path is eligible for localization.
def localized?(request)
# Ignore requests which match the ignored paths:
path_info = request.path_info
return false if @ignore.any?{|pattern| path_info[pattern] != nil}
path = request.url.path.encoded
return false if @ignore.any?{|pattern| path[pattern] != nil}

return true
end
Expand Down
2 changes: 1 addition & 1 deletion lib/utopia/localization/resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def localized_response(request, response, localization)

if locale = localization.locale
response.headers[CONTENT_LANGUAGE] = locale
response.headers[CONTENT_LOCATION] = localization.localized_path(request.path_info)
response.headers[CONTENT_LOCATION] = localization.localized_path(request.url.path.encoded)
end

return response
Expand Down
22 changes: 14 additions & 8 deletions lib/utopia/path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# Released under the MIT License.
# Copyright, 2009-2025, by Samuel Williams.

require "protocol/url/path"

module Utopia
# Represents a path as an array of path components. Useful for efficient URL manipulation.
class Path
Expand Down Expand Up @@ -72,11 +74,11 @@ def shortest_path(root)
self.class.shortest_path(self, root)
end

# Decode URL-encoded path content, converting `+` to whitespace and percent-encoded bytes to their corresponding characters.
# Decode URL-encoded path content, preserving literal `+` characters.
# @parameter string [String] The encoded content.
# @returns [String] The decoded content.
def self.unescape(string)
string.tr("+", " ").gsub(/((?:%[0-9a-fA-F]{2})+)/n) do
string.gsub(/((?:%[0-9a-fA-F]{2})+)/n) do
[$1.delete("%")].pack("H*")
end
end
Expand All @@ -89,12 +91,14 @@ def self.[] path
end

# Convert a path value into an array of components.
# @parameter path [Utopia::Path | String] The path.
# @parameter path [Utopia::Path | Protocol::URL::Path | String] The path.
# @returns [Array] The path components.
def self.split(path)
case path
when Path
return path.to_a
when Protocol::URL::Path
return path.components
when Array
return path
when String
Expand All @@ -105,10 +109,10 @@ def self.split(path)
end

# Construct a path from URL-encoded text. This is an optimized direct entry point used by controller invocations.
# @parameter string [String] The encoded path.
# @parameter path [Protocol::URL::Path | String] The encoded path.
# @returns [Path] The decoded path.
def self.from_string(string)
self.new(unescape(string).split(SEPARATOR, -1))
def self.from_string(path)
self.new(Protocol::URL::Path[path].components)
end

# Load a path from its serialized form.
Expand All @@ -126,16 +130,18 @@ def self.dump(instance)
end

# Coerce a value into a path.
# @parameter path [Path | Array | String | Object | Nil] The value to coerce.
# @parameter path [Path | Protocol::URL::Path | Array | String | Object | Nil] The value to coerce.
# @returns [Path | Nil] The coerced path.
def self.create(path)
case path
when Path
return path
when Protocol::URL::Path
return self.new(path.components)
when Array
return self.new(path)
when String
return self.new(unescape(path).split(SEPARATOR, -1))
return self.from_string(path)
when nil
return nil
else
Expand Down
Loading
Loading