2019-09-11 20:39

A Better Way of Working With URIs in Ruby

In this post I discuss the Addressable gem and the ways in which it improves dealing with URIs in Ruby code. If you find the term URI confusing then I recommend reading this article first.

Before diving into the strengths of Addressable, let's look at a typical code example:

class Invoice
  BASE_URI = "https://api.example.com"
  INVOICE_URI = "#{BASE_URI}/customers/%<customer_id>i/invoices/%<invoice_id>i"

  def self.uri(customer_id:, invoice_id:, format: nil)
    uri = INVOICE_URI % { customer_id: customer_id, invoice_id: invoice_id }
    uri += ".#{format}" if format
    uri
  end
end

Using Addressable it could look like this:

class Invoice
  BASE_URI = "https://api.example.com"
  INVOICE_URI = "#{BASE_URI}/customers/{customer_id}/invoices/{invoice_id}{.format}"

  def self.uri(customer_id:, invoice_id:, format: nil)
    Addressable::Template.new(INVOICE_URI).expand({
      customer_id: customer_id,
      invoice_id: invoice_id,
      format: format
    })
  end
end

If we run both examples the results will be the same:

https://api.example.com/customers/1/invoices/2
https://api.example.com/customers/1/invoices/2.json

While you could argue that the example using Addressable consists of a few more lines, the benefit is that you no longer need a condition to deal with format. Additionally, we don't have to do any string concatenation which is a bit of a code smell.

Before we continue, let's mention the two foundations on which Addressable rests:

  1. IRI (Internationalized Resource Identifier)
  2. URI Template

IRI deals with URIs like iñtërnâtiônàlizætiøn.com i.e. URIs containing non-ASCII characters.

A URI template allows you to define a URI based on variables, which are expanded based on certain rules. RFC 6570 describes four so-called "template levels", all of which are supported by Addressable.

Template Level 1

This is the simplest level, which the RFC describes as:

[...] most template processors implemented prior to this specification have only implemented the default expression type, we refer to these as Level 1 templates.

Example:

template = Addressable::Template.new("https://www.example.com/{user}/{resource}")

template.expand({ user: 'erik', resource: 'archived documents' })

#=> https://www.example.com/erik/archived%20documents

Notice that Addressable properly escapes the space between archived and documents.

Template Level 2

RFC 6570 states about level 2:

Level 2 templates add the plus ("+") operator, for expansion of values that are allowed to include reserved URI characters (Section 1.5), and the crosshatch ("#") operator for expansion of fragment identifiers.

template = Addressable::Template.new("https://www.example.com/{+department}/people")

template.expand({ department: 'technology/r&d' })

#=> https://www.example.com/technology/r&d/people

The + in {+department} instructs the template to retain reserved URI characters instead of encoding them. Without the + the result would be https://www.example.com/technology%2Fr%26d/people.

Level 2 has one more trick up its sleeve namely fragments:

template = Addressable::Template.new("https://www.example.com/{+department}/people{#person}")

template.expand({ department: 'technology/r&d', person: 'erik' })

#=> https://www.example.com/technology/r&d/people#erik

Template Level 3

Level 3 turns it up a notch:

Level 3 templates allow multiple variables per expression, each separated by a comma, and add more complex operators for dot-prefixed labels, slash-prefixed path segments, semicolon-prefixed path parameters, and the form-style construction of a query syntax consisting of name=value pairs that are separated by an ampersand character.

String expansion with multiple variables:

template = Addressable::Template.new("https://www.example.com/map?{lat,long}")

template.expand({ lat: 37.384, long: -122.264 })

#=> https://www.example.com/map?37.384,-122.264

Reserved expansion with multiple variables:

template = Addressable::Template.new("https://www.example.com/{+department,floor}")

template.expand({ department: 'technology/r&d', floor: 1 })

#=> https://www.example.com/technology/r&d,1

Fragment expansion with multiple variables:

template = Addressable::Template.new("https://www.example.com/people{#id,person}")

template.expand({ id: 1001, person: 'erik' })

#=> https://www.example.com/people#1001,erik

Label expansion, dot-prefixed:

template = Addressable::Template.new("https://www.example.com/versions/v{.major,minor,build}")

template.expand({ major: 1, minor: 2, build: 1103 })

#=> https://www.example.com/versions/v.1.2.1103

Path segments, slash-prefixed:

template = Addressable::Template.new("https://www.example.com{/path,subpath}")

template.expand({ path: 'legal', subpath: 'terms-of-service' })

#=> https://www.example.com/legal/terms-of-service

Path-style parameters, semicolon-prefixed:

template = Addressable::Template.new("https://www.example.com/action?op=crop{;x,y,w,h}")

template.expand({ x: 0, y: 20, w: 256, h: 256 })

#=> https://www.example.com/action?op=crop;x=0;y=20;w=256;h=256

Form-style query, ampersand-separated:

template = Addressable::Template.new("https://www.example.com/action{?op,x,y,w,h}")

template.expand({ op: 'crop', x: 0, y: 20, w: 256, h: 256 })

#=> https://www.example.com/action?op=crop&x=0&y=20&w=256&h=256

Form-style query continuation:

template = Addressable::Template.new("https://www.example.com/action?op=crop{&x,y,w,h}")

template.expand({ x: 0, y: 20, w: 256, h: 256 })

#=> https://www.example.com/action?op=crop&x=0&y=20&w=256&h=256

Template Level 4

Finally, Level 4 templates add value modifiers as an optional suffix to each variable name. A prefix modifier (":") indicates that only a limited number of characters from the beginning of the value are used by the expansion (Section 2.4.1). An explode ("*") modifier indicates that the variable is to be treated as a composite value, consisting of either a list of names or an associative array of (name, value) pairs, that is expanded as if each member were a separate variable (Section 2.4.2).

String expansion with value modifiers:

template = Addressable::Template.new("https://www.example.com/{user:1}/{user}/{resource}")

template.expand({ user: 'erik', resource: 'archived documents' })

#=> https://www.example.com/e/erik/archived%20documents

Notice the /e/ path segment.

Explode (*) modifier examples:

template = Addressable::Template.new("https://www.example.com/map?{coords*}")

template.expand({ coords: [37.384, -122.264] })

#=> https://www.example.com/map?37.384,-122.264
template = Addressable::Template.new("https://www.example.com/map?{coords*}")

template.expand({ coords: { lat: 37.384, long: -122.264 } })

#=> https://www.example.com/map?lat=37.384,long=-122.264

Parsing

Besides creating URIs, Addressable can also be used to parse URIs.

Suppose you have to deal with UTM parameters:

template = Addressable::Template.new("http://{host}{/segments*}/{?utm_source,utm_medium}{#fragment}")

uri = Addressable::URI.parse("http://example.com/a/b/c/?utm_source=1&utm_medium=2#preface")

template.extract(uri)

#=> {"host"=>"example.com", "segments"=>["a", "b", "c"], "utm_source"=>"1", "utm_medium"=>"2", "fragment"=>"preface"}

For other examples see Addressable's readme. Also check out its tests, they're easy to read.

I am now using Addressable whenever I have to craft or parse URIs. Let me know if you spot any errors or omissions.

Permalink — Comments or kudos? Find me on Twitter.

Are you a project or product manager? Ship better software with Releasewise!