Protocol::URLGuidesWorking with References

Working with References

This guide explains how to use class Protocol::URL::Reference for managing URLs with query parameters and fragments.

Overview

class Protocol::URL::Reference extends class Protocol::URL::Relative with support for query strings and fragments. References are ideal when you need to work with URLs that include query parameters (like ?page=2&sort=name) or fragments (like #section-3).

Creating References

You can create references in several ways:

Parsing External URLs (Untrusted Data)

Use Protocol::URL.parse or Protocol::URL.[] to parse URL strings from external sources (user input, APIs, web pages). These methods reject whitespace and control characters, preserve the encoded path structure, and expose decoded path components:

# Parse a reference with query and fragment:
reference = Protocol::URL["/api/users?active=true&role=admin#list"]
reference.path.to_s # => "/api/users"
reference.query     # => "active=true&role=admin"
reference.fragment  # => "list"

Constructing from Known Values (Trusted Data)

Use Protocol::URL::Reference.new when you have known good values from your code. This method does not validate its input. Path strings contain encoded URL syntax, query strings are already formatted, and fragments are decoded:

require "protocol/url"

# Reference with path only:
reference = Protocol::URL::Reference.new("/api/users")
reference.to_s  # => "/api/users"

# Reference with query string:
reference = Protocol::URL::Reference.new("/search", "q=ruby&page=2")
reference.to_s  # => "/search?q=ruby&page=2"

# Reference with all components:
reference = Protocol::URL::Reference.new("/api/users", "status=active", "results")
reference.to_s  # => "/api/users?status=active#results"

# Using parameters (recommended for query strings):
reference = Protocol::URL::Reference.new("/search", nil, nil, {q: "ruby", page: 2})
reference.to_s  # => "/search?q=ruby&page=2"

# Construct a path from decoded application values explicitly:
path = Protocol::URL::Path.for(["", "users", "Samuel Williams"])
reference = Protocol::URL::Reference.new(path)
reference.to_s  # => "/users/Samuel%20Williams"

Understanding Encoding

References retain their path as a class Protocol::URL::Path. Encoded and decoded path values therefore use the same explicit boundaries as Path itself.

With parse() - Preserves Encoded Input and Decodes Components

parse() expects already-encoded URLs. It returns a class Protocol::URL::Path which preserves the exact encoded segments and decodes components only when requested:

ref = Protocol::URL::Reference.parse("path%20with%20spaces?foo=bar#frag%20ment")
ref.path.to_s       # => "path%20with%20spaces" (encoded URL path)
ref.path.segments   # => ["path%20with%20spaces"] (encoded segments)
ref.path.components # => ["path with spaces"] (decoded components)
ref.fragment  # => "frag ment" (decoded)
ref.to_s      # => "path%20with%20spaces?foo=bar#frag%20ment" (re-encoded)

With new() - Expects an Encoded Path

new() treats a path string as encoded URL syntax. The fragment remains a decoded value and is encoded during output:

ref = Protocol::URL::Reference.new("path%20with%20spaces", "foo=bar", "frag ment")
ref.path.components # => ["path with spaces"]
ref.fragment  # => "frag ment"
ref.to_s      # => "path%20with%20spaces?foo=bar#frag%20ment"

Use Protocol::URL::Path.for when starting with decoded application data:

path = Protocol::URL::Path.for(["I", "❤️", "UNICODE"])
ref = Protocol::URL::Reference.new(path)
ref.to_s  # => "I/%E2%9D%A4%EF%B8%8F/UNICODE"

Accessing Components

References provide accessors for all URL components:

reference = Protocol::URL["/api/v1/users?page=2&limit=50#results"]

# Path component:
reference.path.to_s # => "/api/v1/users"

# Query string (unparsed):
reference.query     # => "page=2&limit=50"

# Fragment (decoded):
reference.fragment  # => "results"

Updating References

The Protocol::URL::Reference#with method creates a new reference with modified components. This follows an immutable pattern - the original reference is unchanged.

Modifying the Path

base = Protocol::URL::Reference.new("/api/v1/users")

# Append to path with relative reference:
detail = base.with(path: "123")
detail.to_s  # => "/api/v1/users/123"

# Navigate with relative paths:
sibling = detail.with(path: "../groups")
sibling.to_s  # => "/api/v1/groups"

# Replace with absolute path:
root = base.with(path: "/status")
root.to_s  # => "/status"

The path resolution follows RFC 3986 rules, using Protocol::URL::Path#join internally.

Updating Query Parameters

base = Protocol::URL::Reference.new("/search", "q=ruby")

# Replace query string:
filtered = base.with(query: "q=ruby&lang=en")
filtered.to_s  # => "/search?q=ruby&lang=en"

# Remove query string:
no_query = base.with(query: nil)
no_query.to_s  # => "/search"

Updating Fragments

doc = Protocol::URL::Reference.new("/docs/guide")

# Add fragment:
section = doc.with(fragment: "installation")
section.to_s  # => "/docs/guide#installation"

# Change fragment:
different = section.with(fragment: "usage")
different.to_s  # => "/docs/guide#usage"

# Remove fragment:
no_fragment = section.with(fragment: nil)
no_fragment.to_s  # => "/docs/guide"

Updating Multiple Components

You can update multiple components at once:

base = Protocol::URL::Reference.new("/api/users")

modified = base.with(
	path: "posts",
	query: "author=john&status=published",
	fragment: "top"
)
modified.to_s  # => "/api/posts?author=john&status=published#top"

Combining with Absolute URLs

References can be combined with absolute URLs to create complete URLs:

# Base absolute URL:
base = Protocol::URL["https://api.example.com/v1"]

# Relative reference:
reference = Protocol::URL::Reference.new("users", "active=true", "list")

# Combine them:
result = base + reference
result.to_s  # => "https://api.example.com/v1/users?active=true#list"

Fragment Encoding

Fragments are automatically decoded when parsing and encoded when converting to strings:

# Parsing decodes percent-encoded fragments:
reference = Protocol::URL["/docs#hello%20world"]
reference.fragment  # => "hello world" (decoded)
reference.to_s      # => "/docs#hello%20world" (re-encoded)

# Special characters are preserved:
reference = Protocol::URL["/page#section/1.2?note"]
reference.fragment  # => "section/1.2?note"
# Characters like / and ? are allowed in fragments per RFC 3986

Practical Examples

Building Paginated API Requests

# Start with base endpoint:
endpoint = Protocol::URL::Reference.new("/api/users", "page=1&limit=20")

# Parse query string into parameters:
endpoint.parse_query!
endpoint.parameters  # => {"page" => "1", "limit" => "20"}

# Update page number:
next_page = endpoint.with(parameters: {"page" => "2"})
next_page.to_s  # => "/api/users?page=2&limit=20"

# Add filtering (merge with existing parameters):
filtered = endpoint.with(parameters: {"status" => "active"})
filtered.to_s  # => "/api/users?page=1&limit=20&status=active"

Search Results with Filters

# Initial search:
search = Protocol::URL::Reference.new("/search", "q=ruby")

# Add language filter:
filtered = search.with(query: "q=ruby&lang=en")
filtered.to_s  # => "/search?q=ruby&lang=en"

# Jump to specific result:
result = filtered.with(fragment: "result-5")
result.to_s  # => "/search?q=ruby&lang=en#result-5"

Best Practices

When to Use References

parse() vs new()

Choose the right method based on your data source:

  • Use parse() or [] for external/untrusted data (user input, URLs from web pages, API responses). These methods reject whitespace and control characters while preserving encoded path boundaries.
  • Use new() for known encoded paths or a Path constructed explicitly from decoded components. It skips whole-reference parsing and validation.
# External data - use parse():
user_input = "/search?q=ruby%20gems"
reference = Protocol::URL[user_input]  # Validates and decodes

# Internal decoded data - make the encoding boundary explicit:
path = Protocol::URL::Path.for(["", "api", "users"])
reference = Protocol::URL::Reference.new(path, "status=active")

Query String Management

The library provides built-in parameter handling through the parameters attribute:

# Create reference with query string:
reference = Protocol::URL::Reference.new("/search", "q=ruby&page=2")

# Parse query string into parameters hash:
reference.parse_query!
reference.parameters  # => {"q" => "ruby", "page" => "2"}
reference.query       # => nil (cleared after parsing)

# Update with new parameters (merged):
updated = reference.with(parameters: {"lang" => "en"})
updated.to_s  # => "/search?q=ruby&page=2&lang=en"

# Replace parameters completely (merge: false):
replaced = reference.with(parameters: {"q" => "python"}, merge: false)
replaced.to_s  # => "/search?q=python"

Alternatively, you can provide parameters directly when creating a reference:

# Create with parameters directly:
reference = Protocol::URL::Reference.new("/search", nil, nil, {"q" => "ruby", "page" => "2"})
reference.to_s  # => "/search?q=ruby&page=2"

Immutability

References are immutable - with always returns a new instance:

original = Protocol::URL::Reference.new("/api/users")
modified = original.with(query: "active=true")

original.to_s  # => "/api/users" (unchanged)
modified.to_s  # => "/api/users?active=true" (new instance)