URI #

URLs are among the most frequently manipulated data structures in web applications — but manipulating URLs with plain strings is full of traps: how to combine a base URL with a path without double slashes, how to extract query parameters from a complex URL, how to ensure special characters are encoded correctly. The URI module in Ruby’s standard library provides a URL representation as an object with methods, not just a string manipulated manually. With URI, you can parse URLs into their components, modify one component without breaking others, build URLs programmatically, and handle encoding correctly. This article covers the entire URI API — from basic parsing to complex URL building.

URI Structure #

Before diving into code, it’s important to understand the anatomy of a complete URI and how Ruby maps it to methods.

https://alice:[email protected]:8443/v1/users?role=admin&aktif=true#hasil
  │      │     │        │               │    │        │                    │
  scheme user  password host            port path     query               fragment
require "uri"

url = URI.parse("https://alice:[email protected]:8443/v1/users?role=admin&aktif=true#hasil")

url.scheme    # => "https"
url.user      # => "alice"
url.password  # => "secret"
url.host      # => "api.example.com"
url.port      # => 8443
url.path      # => "/v1/users"
url.query     # => "role=admin&aktif=true"
url.fragment  # => "hasil"

# Full representation
url.to_s
# => "https://alice:[email protected]:8443/v1/users?role=admin&aktif=true#hasil"

# userinfo — user:password as a single string
url.userinfo  # => "alice:secret"

# host with port
"#{url.host}:#{url.port}"  # => "api.example.com:8443"

# origin — scheme + host + port
"#{url.scheme}://#{url.host}:#{url.port}"  # => "https://api.example.com:8443"
flowchart TD
    A["URI.parse(url_string)"] --> B{URI type}
    B --> C[URI::HTTPS]
    B --> D[URI::HTTP]
    B --> E[URI::FTP]
    B --> F[URI::MailTo]
    B --> G[URI::Generic]

    C --> H[scheme / host / port\npath / query / fragment\nuser / password]
    D --> H
    E --> I[scheme / host / port\npath / userinfo]
    F --> J[to / headers]

Parsing URIs #

URI.parse #

URI.parse is the main entry point — it analyzes a URL string and returns a URI object from the appropriate subclass.

require "uri"

# URI::HTTPS for HTTPS
https_uri = URI.parse("https://example.com/path")
https_uri.class   # => URI::HTTPS

# URI::HTTP for HTTP
http_uri = URI.parse("http://example.com/path")
http_uri.class    # => URI::HTTP

# URI::FTP
ftp_uri = URI.parse("ftp://files.example.com/data.zip")
ftp_uri.class     # => URI::FTP

# URI::MailTo
mailto = URI.parse("mailto:[email protected]")
mailto.class      # => URI::MailTo
mailto.to         # => "[email protected]"

# URI with only a path (relative)
relative = URI.parse("/api/v1/users")
relative.class    # => URI::Generic
relative.path     # => "/api/v1/users"

# Invalid URI
begin
  URI.parse("not a valid uri !!!!")
rescue URI::InvalidURIError => e
  puts "Invalid URI: #{e.message}"
end

Default Components #

Every HTTP/HTTPS scheme has a default port automatically used when no port is mentioned.

require "uri"

# HTTP — default port 80
http = URI.parse("http://example.com/path")
http.port          # => 80  (default, not from the string)
http.default_port  # => 80

# HTTPS — default port 443
https = URI.parse("https://example.com/path")
https.port          # => 443
https.default_port  # => 443

# Explicit port different from the default
custom = URI.parse("https://example.com:8443/path")
custom.port          # => 8443
custom.default_port  # => 443

# Check whether a non-default port is used
def non_default_port?(uri)
  uri.port != uri.default_port
end

non_default_port?(https)   # => false
non_default_port?(custom)  # => true

Modifying URI Components #

URI objects can be modified — you can change one component without touching the others.

require "uri"

base = URI.parse("https://api.example.com/v1/users")

# Change the path
base.path = "/v2/users"
base.to_s   # => "https://api.example.com/v2/users"

# Change the host
base.host = "api.staging.example.com"
base.to_s   # => "https://api.staging.example.com/v2/users"

# Add a query
base.query = "page=1&per_page=20"
base.to_s   # => "https://api.staging.example.com/v2/users?page=1&per_page=20"

# Change the scheme
base.scheme = "http"
base.to_s   # => "http://api.staging.example.com/v2/users?page=1&per_page=20"

# Dup before modifying — if you don't want to change the original
original = URI.parse("https://api.example.com/v1/users")
staging = original.dup
staging.host = "api.staging.example.com"

original.to_s  # => "https://api.example.com/v1/users"
staging.to_s   # => "https://api.staging.example.com/v1/users"

Building URIs Programmatically #

Besides parsing strings, you can build URIs from their components.

require "uri"

# URI::HTTP.build — from a Hash of components
uri = URI::HTTP.build(
  host: "api.example.com",
  path: "/v1/users",
  query: "role=admin"
)
uri.to_s   # => "http://api.example.com/v1/users?role=admin"

# URI::HTTPS.build
uri = URI::HTTPS.build(
  host: "api.example.com",
  port: 8443,
  path: "/v1/products",
  query: "active=true&category=electronics"
)
uri.to_s   # => "https://api.example.com:8443/v1/products?active=true&category=electronics"

# Build from an array [scheme, userinfo, host, port, registry, path, opaque, query, fragment]
uri = URI::Generic.build(
  scheme: "https",
  host: "example.com",
  path: "/path",
  fragment: "section-1"
)
uri.to_s   # => "https://example.com/path#section-1"

Building Query Strings #

The query string is the URI part most often built dynamically. URI doesn’t provide a dedicated method for this, but the pattern is well established.

require "uri"

# Building a query string from a Hash
def hash_to_query(params)
  params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v.to_s)}" }.join("&")
end

# A more idiomatic way: URI.encode_www_form
params = { role: "admin", active: true, page: 1 }
URI.encode_www_form(params)
# => "role=admin&active=true&page=1"

# Adding a query to an existing URI
def add_query(uri_string, params)
  uri = URI.parse(uri_string)
  existing = URI.decode_www_form(uri.query || "").to_h
  merged = existing.merge(params.transform_keys(&:to_s))
  uri.query = URI.encode_www_form(merged)
  uri.to_s
end

add_query("https://api.example.com/users?role=admin", { page: 2, per_page: 20 })
# => "https://api.example.com/users?role=admin&page=2&per_page=20"

# Parsing a query string back into a Hash
def query_to_hash(uri_string)
  uri = URI.parse(uri_string)
  return {} if uri.query.nil? || uri.query.empty?
  URI.decode_www_form(uri.query).to_h
end

query_to_hash("https://api.example.com/users?role=admin&active=true&page=1")
# => {"role"=>"admin", "active"=>"true", "page"=>"1"}

Encoding and Decoding #

URI encoding (percent encoding) is the process of converting URL-unsafe characters into %XX representations. This matters for characters like spaces, &, =, #, and non-ASCII characters.

require "uri"

# encode_www_form_component — encode a single value (space becomes +)
URI.encode_www_form_component("hello world")    # => "hello+world"
URI.encode_www_form_component("Alice & Bob")    # => "Alice+%26+Bob"
URI.encode_www_form_component("price=50.000")   # => "price%3D50.000"
URI.encode_www_form_component("Jakarta Selatan") # => "Jakarta+Selatan"

# decode_www_form_component — decode back
URI.decode_www_form_component("hello+world")    # => "hello world"
URI.decode_www_form_component("Alice+%26+Bob")  # => "Alice & Bob"

# encode_www_form — encode a Hash/Array as a query string (spaces become +)
URI.encode_www_form([["nama", "Alice Bob"], ["kota", "Jakarta Selatan"]])
# => "nama=Alice+Bob&kota=Jakarta+Selatan"

URI.encode_www_form({ nama: "Alice Bob", filter: "a&b" })
# => "nama=Alice+Bob&filter=a%26b"

# decode_www_form — parse a query string into an Array of pairs
URI.decode_www_form("nama=Alice+Bob&kota=Jakarta+Selatan")
# => [["nama", "Alice Bob"], ["kota", "Jakarta Selatan"]]

URI.decode_www_form("nama=Alice+Bob&kota=Jakarta+Selatan").to_h
# => {"nama"=>"Alice Bob", "kota"=>"Jakarta Selatan"}

# encode_uri_component — encode a path segment (spaces become %20, not +)
URI.encode_uri_component("hello world")    # => "hello%20world"  (Ruby 3.2+)

# For older Ruby versions, use CGI.escape or ERB::Util.url_encode
require "cgi"
CGI.escape("hello world")           # => "hello+world"
CGI.unescape("hello+world")         # => "hello world"
CGI.escapeURIComponent("hello world")  # => "hello%20world"  (Ruby 3.2+)
# When to use encode_www_form_component vs encode_uri_component?
# encode_www_form_component: for values in a query string (? ... )
# encode_uri_component: for path segments (/path/segment)

base = "https://example.com"
path_param = "produk/elektronik & komputer"
query_param = "kata kunci & filter"

# Path: spaces become %20
encoded_path = URI.encode_uri_component(path_param)   # Ruby 3.2+
# => "produk%2Felektronik%20%26%20komputer"

# Query: spaces become +
encoded_query = URI.encode_www_form_component(query_param)
# => "kata+kunci+%26+filter"

url = "#{base}/#{encoded_path}?q=#{encoded_query}"
# => "https://example.com/produk%2Felektronik%20%26%20komputer?q=kata+kunci+%26+filter"

URI Validation #

require "uri"

# URI.parse raises an exception for very invalid URIs
# But some "weird" strings are still accepted

def valid_uri?(string)
  uri = URI.parse(string)
  uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
rescue URI::InvalidURIError
  false
end

valid_uri?("https://example.com")        # => true
valid_uri?("http://example.com/path")    # => true
valid_uri?("ftp://files.example.com")    # => false (not HTTP/HTTPS)
valid_uri?("not a uri")                  # => false
valid_uri?("")                           # => false

# Stricter validation
def valid_http_url?(string)
  return false if string.nil? || string.strip.empty?

  uri = URI.parse(string.strip)
  return false unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
  return false if uri.host.nil? || uri.host.empty?

  true
rescue URI::InvalidURIError
  false
end

valid_http_url?("https://example.com")        # => true
valid_http_url?("https://example.com/path?q=1") # => true
valid_http_url?("javascript:alert(1)")        # => false
valid_http_url?(nil)                          # => false
valid_http_url?("")                           # => false

URI.join — Combining URIs #

URI.join combines a base URI with a relative URI following RFC 3986 rules — the correct way to build URLs from a base and a path.

require "uri"

base = "https://api.example.com/v1/"

# URI.join follows RFC 3986 rules
URI.join(base, "users").to_s
# => "https://api.example.com/v1/users"

URI.join(base, "users/123").to_s
# => "https://api.example.com/v1/users/123"

# Careful: a path without a leading slash replaces the last segment
URI.join("https://api.example.com/v1/users", "products").to_s
# => "https://api.example.com/v1/products"  (not /v1/users/products!)

# With a leading slash: an absolute path from the root
URI.join(base, "/v2/users").to_s
# => "https://api.example.com/v2/users"  (overrides the whole path)

# Chained joins
URI.join("https://api.example.com/", "v1/", "users/", "123").to_s
# => "https://api.example.com/v1/users/123"

# ANTI-PATTERN: string concatenation prone to double slashes
base_url = "https://api.example.com/v1"
endpoint = "/users"
"#{base_url}#{endpoint}"    # => "https://api.example.com/v1/users"  (happens to be right)
"#{base_url}/#{endpoint}"   # => "https://api.example.com/v1//users" (double slash!)

# CORRECT: use URI.join
URI.join("https://api.example.com/v1/", "users").to_s
# => "https://api.example.com/v1/users"

Real-World Usage Patterns #

HTTP Client Helper #

require "uri"
require "net/http"

class ApiClient
  def initialize(base_url)
    @base_uri = URI.parse(base_url)
  end

  def get(path, params = {})
    uri = build_uri(path, params)
    Net::HTTP.get_response(uri)
  end

  private

  def build_uri(path, params = {})
    uri = @base_uri.dup
    uri.path = File.join(uri.path, path)
    uri.query = URI.encode_www_form(params) unless params.empty?
    uri
  end
end

client = ApiClient.new("https://api.example.com/v1")
response = client.get("/users", { role: "admin", page: 1 })

A Safe URL Builder #

require "uri"

class UrlBuilder
  def initialize(base)
    @uri = URI.parse(base)
    @params = URI.decode_www_form(@uri.query || "").to_h
  end

  def path(new_path)
    @uri.path = new_path
    self
  end

  def param(key, value)
    @params[key.to_s] = value.to_s
    self
  end

  def params(hash)
    hash.each { |k, v| param(k, v) }
    self
  end

  def fragment(value)
    @uri.fragment = value
    self
  end

  def build
    @uri.query = @params.empty? ? nil : URI.encode_www_form(@params)
    @uri.to_s
  end

  def to_s
    build
  end
end

url = UrlBuilder.new("https://api.example.com")
  .path("/v2/products")
  .param(:category, "electronics")
  .param(:min_price, 100_000)
  .param(:max_price, 5_000_000)
  .fragment("list")
  .build

# => "https://api.example.com/v2/products?category=electronics&min_price=100000&max_price=5000000#list"

URL Sanitization and Normalization #

require "uri"

def normalize_url(input)
  # Add a scheme if missing
  input = "https://#{input}" unless input.match?(%r{\A[a-z][a-z0-9+\-.]*://}i)

  uri = URI.parse(input)

  # Make sure the scheme is lowercase
  uri.scheme = uri.scheme.downcase

  # Remove the trailing slash from the path (except the root)
  uri.path = uri.path.chomp("/") if uri.path != "/"

  # Remove the fragment (for canonical URLs)
  uri.fragment = nil

  # Remove an empty query
  uri.query = nil if uri.query&.empty?

  uri.to_s
rescue URI::InvalidURIError
  nil
end

normalize_url("HTTPS://Example.COM/path/")
# => "https://example.com/path"

normalize_url("example.com/path")
# => "https://example.com/path"

normalize_url("https://example.com/path#section")
# => "https://example.com/path"

Summary #

  • URI.parse for parsing URLs — returns an object with scheme, host, port, path, query, fragment, user, password methods; safer and more expressive than manual string manipulation.
  • URI.encode_www_form and URI.decode_www_form — the most idiomatic way to build and parse query strings; both handle encoding correctly.
  • URI.join for combining URLs — follows RFC 3986 rules; avoid string concatenation prone to double slashes or unwanted path overrides.
  • Dup before modifying — URI objects can be modified; use uri.dup if you want to keep the original version.
  • Validate the scheme explicitlyURI.parse accepts many formats; always check uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) for web URL validation.
  • encode_www_form_component for queries, encode_uri_component for paths — they differ in how spaces are encoded (+ vs %20) and which characters get encoded.
  • URI::HTTPS.build for building from components — safer than string interpolation when components come from variables.

← Previous: CSV   Next: Net::HTTP →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact