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
44 changes: 44 additions & 0 deletions lib/rage/controller/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -667,4 +667,48 @@ def self.binary_params_for?(_)
def reset_session
session.clear
end

# Generates a full URL for the specified controller and action.
#
# @param controller [String] the controller name in path form
# @param action [String] the action name
# @param host [String] override the host
# @param port [Integer] override the port
# @param protocol [String] override the protocol
# @param only_path [Boolean] if true, return only the path without host and protocol
# @param route_params [Hash] route parameters to substitute into the path
# @return [String] the generated URL or path
# @raise [ArgumentError] if no route is defined for the controller/action pair
#
# @example
# url_for(controller: "users", action: "show", id: 1)
# # => "https://example.com/users/1"
# @example With explicit host and protocol
# url_for(controller: "users", action: "show", id: 1, host: "api.example.com", protocol: "http")
# # => "http://api.example.com/users/1"
# @example With non-standard port
# url_for(controller: "users", action: "show", id: 1, port: 3000)
# # => "http://localhost:3000/users/1"
# @example Path only
# url_for(controller: "users", action: "show", id: 1, only_path: true)
# # => "/users/1"
#
# @see Rage::Router::Util.path_for
def url_for(controller:, action:, host: request.host, port: request.port, protocol: request.protocol, only_path: false, **route_params)
path = Rage::Router::Util.path_for(controller:, action:, **route_params)
return path if only_path

normalized_protocol = protocol.delete_suffix("://")
request_protocol = request.protocol.delete_suffix("://")

port_string = if (normalized_protocol == "http" && port == 80) || (normalized_protocol == "https" && port == 443)
""
elsif port == request.port && ((request_protocol == "http" && port == 80) || (request_protocol == "https" && port == 443))
""
else
":#{port}"
end

"#{normalized_protocol}://#{host}#{port_string}#{path}"
end
end
60 changes: 60 additions & 0 deletions lib/rage/router/util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,66 @@ def route_uri_pattern(controller_class, action_name)
route[:meta][:controller_class] == controller_class && route[:meta][:action] == action_name
}[:path]
end

@@path_builders = Hash.new { |h, k| h[k] = {} }

# Generates a URL path for the specified controller and action.
#
# @param controller [String] the controller name in path form (e.g., "users", "api/v1/photos")
# @param action [String] the action name (e.g., "show", "index")
# @param params [Hash] route parameters to substitute into the path
# @return [String] the generated path
# @raise [ArgumentError] if no route is defined for the controller/action pair,
# or if the provided params don't match any available route
#
# @note For wildcard routes (e.g., `/files/*`), use the `wildcard:` keyword argument.
#
# @example Basic usage
# path_for(controller: "users", action: "show", id: 1)
# # => "/users/1"
#
# @example With nested resource
# path_for(controller: "posts", action: "show", user_id: 1, id: 42)
# # => "/users/1/posts/42"
#
# @example With wildcard route
# path_for(controller: "files", action: "show", wildcard: "path/to/file.txt")
# # => "/files/path/to/file.txt"
#
def path_for(controller:, action:, **params)
path_builders = path_builders_for(controller, action)

path_builder = if path_builders.size == 1
path_builders[0]
else
path_builders.find { |pb|
pb.parameters.size == params.size && params.except(*pb.parameters.map(&:last)).empty?
} || raise(ArgumentError, "No matching route for '#{controller}##{action}' with params #{params.keys}")
end

path_builder.call(**params)
end

private

def path_builders_for(controller, action)
@@path_builders[controller][action] ||= begin
routes = Rage.__router.routes.select { |r| r[:meta][:controller] == controller && r[:meta][:action] == action }
raise ArgumentError, "No route defined for '#{controller}##{action}'" if routes.empty?

routes.map do |route|
path_pattern = route[:path]
params = route[:params].map { |param| param == "*" ? "wildcard" : param }

eval <<~RUBY
-> (#{params.map { |param| "#{param}:" }.join(", ")}) do
args = [#{params.join(", ")}].each
"#{path_pattern}".gsub(/(:\\w+|\\*)/) { args.next }
end
RUBY
end
end
end
end

# @private
Expand Down
104 changes: 104 additions & 0 deletions spec/controller/api/url_helpers_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# frozen_string_literal: true

RSpec.describe RageController::API do
let(:klass) { Class.new(RageController::API) }

let(:routes) do
[
{
method: "GET",
path: "/users",
params: [],
meta: { controller: "users", action: "index" }
},
{
method: "GET",
path: "/users/:id",
params: ["id"],
meta: { controller: "users", action: "show" }
},
{
method: "GET",
path: "/posts",
params: [],
meta: { controller: "posts", action: "index" }
}
]
end

before do
allow(Rage.__router).to receive(:routes).and_return(routes)
Rage::Router::Util.class_variable_set(:@@path_builders, Hash.new { |h, k| h[k] = {} })
end

describe "#url_for" do
it "generates a full URL using request host and protocol" do
env = { "rack.url_scheme" => "https", "HTTP_HOST" => "example.com", "SERVER_PORT" => "443" }
url = klass.new(env, nil).url_for(controller: "users", action: "index")

expect(url).to eq("https://example.com/users")
end

it "generates a URL with path params" do
env = { "rack.url_scheme" => "http", "HTTP_HOST" => "example.com", "SERVER_PORT" => "80" }
url = klass.new(env, nil).url_for(controller: "users", action: "show", id: 123)

expect(url).to eq("http://example.com/users/123")
end

it "allows overriding host and protocol" do
env = { "rack.url_scheme" => "http", "HTTP_HOST" => "default.host", "SERVER_PORT" => "80" }
url = klass.new(env, nil).url_for(controller: "users", action: "show", id: 456, host: "custom.host", protocol: "https")

expect(url).to eq("https://custom.host/users/456")
end

it "generates a URL for a different controller" do
env = { "rack.url_scheme" => "https", "HTTP_HOST" => "example.com", "SERVER_PORT" => "443" }
url = klass.new(env, nil).url_for(controller: "posts", action: "index")

expect(url).to eq("https://example.com/posts")
end

context "with only_path: true" do
it "returns only the path" do
env = { "rack.url_scheme" => "https", "HTTP_HOST" => "example.com", "SERVER_PORT" => "443" }
url = klass.new(env, nil).url_for(controller: "users", action: "show", id: 789, only_path: true)

expect(url).to eq("/users/789")
end
end

context "with non-standard port" do
it "includes the port in the URL" do
env = { "rack.url_scheme" => "http", "HTTP_HOST" => "localhost", "SERVER_PORT" => "80" }
url = klass.new(env, nil).url_for(controller: "users", action: "show", id: 111, port: 3000)

expect(url).to eq("http://localhost:3000/users/111")
end

it "includes request port when non-standard" do
env = { "rack.url_scheme" => "http", "HTTP_HOST" => "localhost:3000" }
url = klass.new(env, nil).url_for(controller: "users", action: "index")

expect(url).to eq("http://localhost:3000/users")
end
end

context "with standard port" do
it "omits port 80 for http" do
env = { "rack.url_scheme" => "http", "HTTP_HOST" => "example.com", "SERVER_PORT" => "80" }
url = klass.new(env, nil).url_for(controller: "users", action: "index")

expect(url).to eq("http://example.com/users")
end

it "omits port 443 for https" do
env = { "rack.url_scheme" => "https", "HTTP_HOST" => "example.com", "SERVER_PORT" => "443" }
url = klass.new(env, nil).url_for(controller: "users", action: "index")

expect(url).to eq("https://example.com/users")
end
end
end
end
137 changes: 137 additions & 0 deletions spec/router/util_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,143 @@
end
end

describe "#path_for" do
let(:users_controller) { double }

before do
described_class.class_variable_set(:@@path_builders, Hash.new { |h, k| h[k] = {} })
allow(Rage.__router).to receive(:routes).and_return(routes)
end

context "with no params" do
let(:routes) do
[
{
method: "GET",
path: "/users",
params: [],
meta: { controller: "users", action: "index" }
}
]
end

it "returns the path" do
expect(described_class.path_for(controller: "users", action: "index")).to eq("/users")
end
end

context "with a single param" do
let(:routes) do
[
{
method: "GET",
path: "/users/:id",
params: ["id"],
meta: { controller: "users", action: "show" }
}
]
end

it "returns the path with param substituted" do
expect(described_class.path_for(controller: "users", action: "show", id: 123)).to eq("/users/123")
end

it "raises an error when param is missing" do
expect { described_class.path_for(controller: "users", action: "show") }.to raise_error(ArgumentError)
end

it "raises an error when unexpected param is passed" do
expect { described_class.path_for(controller: "users", action: "show", id: 123, extra: "value") }.to raise_error(ArgumentError)
end
end

context "with multiple params" do
let(:routes) do
[
{
method: "GET",
path: "/users/:user_id/posts/:id",
params: ["user_id", "id"],
meta: { controller: "posts", action: "show" }
}
]
end

it "returns the path with all params substituted" do
expect(described_class.path_for(controller: "posts", action: "show", user_id: 1, id: 42)).to eq("/users/1/posts/42")
end
end

context "with multiple routes for the same controller/action" do
let(:routes) do
[
{
method: "GET",
path: "/photos/:id",
params: ["id"],
meta: { controller: "photos", action: "show" }
},
{
method: "GET",
path: "/users/:user_id/photos/:id",
params: ["user_id", "id"],
meta: { controller: "photos", action: "show" }
}
]
end

it "selects the route matching the provided params" do
expect(described_class.path_for(controller: "photos", action: "show", id: 5)).to eq("/photos/5")
expect(described_class.path_for(controller: "photos", action: "show", user_id: 1, id: 5)).to eq("/users/1/photos/5")
end
end

context "with a wildcard route" do
let(:routes) do
[
{
method: "GET",
path: "/files/*",
params: ["*"],
meta: { controller: "files", action: "show" }
}
]
end

it "returns the path with wildcard substituted" do
expect(described_class.path_for(controller: "files", action: "show", wildcard: "path/to/file.txt")).to eq("/files/path/to/file.txt")
end
end

context "when no route is defined" do
let(:routes) do
[]
end

it "raises an ArgumentError" do
expect { described_class.path_for(controller: "unknown", action: "index") }.to raise_error(ArgumentError, /No route defined/)
end
end

context "multiple calls" do
let(:routes) do
[
{
method: "GET",
path: "/users",
params: [],
meta: { controller: "users", action: "index" }
}
]
end

it "caches the path builders" do
expect(Rage.__router).to receive(:routes).and_return(routes).once
2.times { described_class.path_for(controller: "users", action: "index") }
end
end
end

describe "#route_uri_pattern" do
let(:users_controller) { double }

Expand Down
Loading