Stop concatenating URLs with strings — Use proper tools instead

… and check why 5600+ Rails engineers read also this

Stop concatenating URLs with strings — Use proper tools instead

How many times have you seen code like this in a Ruby application?

base_url = "https://api.example.com"
endpoint = "/users"
user_id = params[:id]

full_url = "#{base_url}#{endpoint}/#{user_id}

At first glance, it looks harmless, but it hides several traps that can lead to hard–to–debug errors.

Problems with Naive URL Concatenation

1. Double or missing slashes

base_url = "https://api.example.com/" # has trailing hash
endpoint = "/users" # has leading hash

url = "#{base_url}{#endpoint}"
# => "https://api.example.com//users"

Double slash is likely not desired. While most servers will handle it, this looks unprofessional.

base_url = "https://api.example.com" # no trailing hash
endpoint = "users" # no leading hash

url = "#{base_url}{#endpoint}"
# => "https://api.example.comusers"

TLD concatenated with path is not a thing we’re looking for.

2. Improper parameter escaping

query = "hello world"
url = "https://api.example.com/search?q=#{query}"
# => "https://api.example.com/search?q=hello world"
# space is not properly encoded

3. Protocol and port issues

host = "localhost:3000"
path = "/api/v1/users"

url = "https://#{host}#{path}"

What if host already contains protocol?

Solutions

1. Ruby’s URI

Ruby has a built–in URI class that solves most of these problems:

require "uri"

base = URI("https://api.example.com")
base.path = "/users"
base.query = URI.encode_www_form(q: "hello world", limit: 10)

puts base.to_s
# => "https://api.example.com/users?q=hello+world&limit=10"

Let’s join some paths:

base_url = URI("https://api.example.com/api/v1/")
URI.join(base_url, "users", "123")
# => #<URI::HTTPS https://api.example.com/api/v1/users/123>

More advanced URI example

class ApiClient
  def initialize(base_url)
    @base_url = URI(base_url)
  end

  def build_url(path, query_params = {})
    uri = @base_url.dup
    uri.path = File.join(uri.path, path)
    uri.query = URI.encode_www_form(query_params) unless query_params.empty?
    uri.to_s
  end
end

client = ApiClient.new("https://api.example.com/v1")
client.build_url("users/123", { include: "profile", format: "json" })
# => https://api.example.com/v1/users/123?include=profile&format=json

2. Pathname for local paths

If you’re working with file paths or local URLs, Pathname can be helpful:

require "pathname"

base_bath = Pathname.new("/var/www/uploads")
user_folder = "user_123"
filename = "avatar.jpg"

full_path = base_path.join(user_folder, filename)
# => #<Pathname:/var/www/uploads/user_123/avatar.jpg>

URI("file://#{full_path}")
# => #<URI::File file:///var/www/uploads/user_123/avatar.jpg>

One more cool trick with pathname is / method (alias for +):

Pathname.new("/var") / "www" / "uploads"
# => #<Pathname:/var/www/uploads>

3. Addresable gem

For more advanced use cases, consider the addressable gem:

require "addressable/uri"

uri = Addressable::URI.new(
  scheme: "https",
  host: "api.example.com",
  path: "users/123",
  query_values: {
    includes: ["profile", "posts"],
    format: "json"
  }
)

puts uri.to_s
# => "https://api.example.com/users/123?includes[]=profile&includes[]=posts&format=json"

4. Rails URL helpers

In Rails applications, use built–in helpers instead of string concatenation whenever possible:

# Instead of:
base_url = "https://myapp.com"
url = "#{base_url}/users/#{user.id}/posts/#{post.id}"

# Use this
url = user_post_url(user, post, host: "https://myapp.com")

# Or this
url = api_v1_user_url(user, host: request.base_url)

5. Test URL building

You might think that having test can catch these URL building issues and you would be partially right. However, many developers stub their HTTP client methods directly:

allow(http_client).to receieve(:get).and_return(mock_response)

This test won’t catch URL formatting issues.

When you stub the HTTP client method itself, malformed ULRs slip through because stub intercepts the call regardless of what URL was passed. A better approach is using WebMock, which sets expectations on the actual URLs being requested:

stub_request(:get, "https://api.example.com/users/123")
  .to_return(status: 200, body: response_json)
  1. WebMock will fail if the URL is malformed
  2. This will catch URL building errors:
  • double slashes
  • missing slashes
  • unescaped parameters

WebMock forces you to be explicit about the exact URLs your code should generated, making URL building bugs much more visible during test runs.

Summary

Concatenating URLs with strings is a recipe for trouble. Instead:

  1. Use URI for basic URL building scenarios
  2. Leverage Pathname for local file paths
  3. Consider addressable for advanced use cases
  4. Utilize Rails URL helpers in Rails applications
  5. Always escape parameters using URI.encode_www_form
  6. Test your external http calls

Your code will be more reliable, easier to maintain and less prone to URL formatting errors.

You might also like