diff --git a/lib/rage/controller/api.rb b/lib/rage/controller/api.rb index 9ddb9491..7e41ddec 100644 --- a/lib/rage/controller/api.rb +++ b/lib/rage/controller/api.rb @@ -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 diff --git a/lib/rage/router/util.rb b/lib/rage/router/util.rb index 1030a21d..452626ea 100644 --- a/lib/rage/router/util.rb +++ b/lib/rage/router/util.rb @@ -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 diff --git a/spec/controller/api/url_helpers_spec.rb b/spec/controller/api/url_helpers_spec.rb new file mode 100644 index 00000000..c147804f --- /dev/null +++ b/spec/controller/api/url_helpers_spec.rb @@ -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 diff --git a/spec/router/util_spec.rb b/spec/router/util_spec.rb index 29da30e4..598e993c 100644 --- a/spec/router/util_spec.rb +++ b/spec/router/util_spec.rb @@ -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 }