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.parsefor parsing URLs — returns an object withscheme,host,port,path,query,fragment,user,passwordmethods; safer and more expressive than manual string manipulation.URI.encode_www_formandURI.decode_www_form— the most idiomatic way to build and parse query strings; both handle encoding correctly.URI.joinfor 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.dupif you want to keep the original version.- Validate the scheme explicitly —
URI.parseaccepts many formats; always checkuri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)for web URL validation.encode_www_form_componentfor queries,encode_uri_componentfor paths — they differ in how spaces are encoded (+ vs %20) and which characters get encoded.URI::HTTPS.buildfor building from components — safer than string interpolation when components come from variables.