Ruby

Structuring return data


If you’re working with APIs, it’s highly likely that you’ll also need to handle the data returned from those APIs (unless you’re only sending data to them). There are various approaches to handling this data, ranging from using basic Hashes to utilizing full model-like classes. In this article, I will explore a few of the most commonly used methods.

Note: For these examples, I’ll use a greatly simplified version of the Github user API response. The whole object is a lot larger, but I don’t want the examples to get too big. Some of the methods will get more bloated than others when used with a lot of fields, which won’t be super apparent from these simplified examples. Keep that in mind as well.

Here’s the JSON response we’ll be using for the examples:

json = <<~JSON
  {
    "login": "djfpaagman",
    "id": 170034,
    "name": "Dennis Paagman"
  }
JSON

I’ve also marked each heading with an emoji, with the follow meanings:

  • 💎 means that it’s part of the Ruby standard library (side note: has anyone yet petitioned the Unicode consortium to add color modifiers to the ‘gem stone’ emoji so we can actually have a red one?).
  • 🚄 means it’s part of the Ruby on Rails framework.
  • 💠 means it’s a separate Ruby gem.

💎 Hash

Omnipresent in Ruby, Hash is basically used for everything all the time. It is also the default output when parsing JSON. Just using a Hash makes sense for many cases as it is easy to work with and straightforward. If you don’t need to do to much with the data, this can be a very straightforward and clean approach.

user = JSON.parse(json)

user["login"] # => djfpaagman

💎 Hash with symbolized keys

By default, JSON.parse uses strings for keys. While reading values from a Hash with string keys works great, using symbols is more Ruby-like and offers several advantages. Symbols are more memory efficient and lookups are faster. But most importantly, using symbols makes the code read nicer, which in turn makes it easier to understand.

user = JSON.parse(json, symbolize_names: true)

user[:login] # => djfpaagman

🚅 Hash with indifferent access (both strings and symbols)

Implements a hash where keys :foo and "foo" are considered to be the same.

Rails also provides HashWithIndifferentAccess, which is essentially a Hash that can retrieve values using either string or symbol keys. It is widely used in Rails.

user = JSON.parse(json).with_indifferent_access

user["login"] # => djfpaagman
user[:login] # => djfpaagman

However, having access through both strings and symbols can have drawbacks. It may become unclear which type of Hash you are working with (should I use strings or symbols for keys? can I use both?), and there is a risk of mixing both approaches in your codebase.

💎 PORO

Our trusted friend, the Plain Old Ruby Object.

Before diving into more specific data structures, you can always start with a basic Ruby class. These classes can be very simple, with just some reader methods and an initializer to set the data.

class GithubUser
  attr_reader :login, :id, :name

  def initialize(attributes)
    @login = attributes["login"]
    @id = attributes["id"]
    @name = attributes["name"]
  end
end

user = GithubUser.new(JSON.parse(json))

user.login # => djfpaagman

As the number of attributes grows, it may make sense to make the initializer smarter. This can be achieved using metaprogramming to check if an instance variable for the attribute exists and set it if it does. You can create a superclass that implements this behavior and allow subclasses to define their own attributes.

However, before reinventing the wheel, there are a few better-suited options available!

💎 Struct

Struct provides a convenient way to create a simple class that can store and fetch values.

Moving up to a more object-oriented structure, Ruby provides a built-in Struct. A Struct is a simple class where you define its fields, and it automatically generates getters and setters for those fields.

You can set fields using the setters (user.name = "...") or initialize an instance of the class with keyword arguments (GithubUser.new(name: "...")).

To pass the JSON directly into the Struct, you can use the ‘double splat’ (**) operator to destructure the JSON hash into keyword arguments:

GithubUser = Struct.new("GithubUser", :login, :id, :name)

user = GithubUser.new(**JSON.parse(json))
  # => #<struct Struct::GithubUser login="djfpaagman", id=170034, name="Dennis Paagman">

After that, you can access the values through regular instance methods or with a hash-like (with indifferent access) syntax:

user.login # => "djfpaagman"
user[:login] # => "djfpaagman"
user["login"] # => "djfpaagman"

The biggest benefit of Structs is their ease of definition, good performance, and the clarity they provide regarding the data format when passing instances of the class around.

However, Structs are strict and do not ignore undefined fields. You need to include all fields in your struct or select specific fields from your response data when passing it into the struct.

This also means that Structs may break if the underlying API changes, such as when a new field is added.

💎 OpenStruct

An OpenStruct is a data structure, similar to a Hash, that allows the definition of arbitrary attributes with their accompanying values. This is accomplished by using Ruby’s metaprogramming to define methods on the class itself.

OpenStruct provides a different approach compared to Struct. While Struct requires you to define the attributes, OpenStruct infers them based on the data you provide:

user = OpenStruct.new(JSON.parse(json))
  # => #<OpenStruct login="djfpaagman", id=170034, name="Dennis Paagman">

user.login #=> "djfpaagman"
user[:login] # => "djfpaagman"
user["login"] # => "djfpaagman"

This flexibility comes at the cost of not offering many advantages over using a simple Hash. In fact, using OpenStruct can result in a performance penalty. The documentation highlights a couple of significant downsides:

Creating an open struct from a small Hash and accessing a few of the entries can be 200 times slower than accessing the hash directly.

This is a potential security issue; building OpenStruct from untrusted user data (e.g. JSON web request) may be susceptible to a “symbol denial of service” attack since the keys create methods and names of methods are never garbage collected.

Therefore, I would advise against using OpenStruct.

💎 Data

Data provides a convenient way to define simple classes for value-alike objects.

In Ruby 3.2, a new class called Data was introduced. It is a simple structure similar to Struct, but with the main difference that it is meant for immutable (non-changing) data. Unlike Struct, it does not create setters for its attributes.

In general, Data has the same advantages and disadvantages as Struct.

GithubUser = Data.define(:login, :id, :name)

user = GithubUser.new(**JSON.parse(json))
  # => #<data GithubUser login="djfpaagman", id=170034, name="Dennis Paagman">

💠 Dry::Struct

dry-struct is a gem built on top of dry-types which provides virtus-like DSL for defining typed struct classes.

dry-struct is a library from the dry-rb ecosystem. It can be best described as a Struct with a nice DSL and a variety of extra features. One of its main features is typed attributes, which provide type safety and allow you to be more strict with the API input you receive. It also offers other useful features such as type coercion, validation, nesting of structs, and optional fields with default values.

Setting up dry-struct requires a bit more code. One additional step we need to take once is to set up a Types module if we want to use the typed attributes:

require 'dry-struct'

module Types
  include Dry.Types()
end

class GithubUser < Dry::Struct
  attribute :login, Types::String
  attribute :id, Types::Coercible::Integer
  attribute :name, Types::String
end

user = GithubUser.new(JSON.parse(json, j))
  # => #<GithubUser login="djfpaagman" id=170034 name="Dennis Paagman">

One of the major benefits of dry-struct is that it ignores undefined attributes by default. This makes it well-suited for use in API clients, as you can define only the attributes you want to use and still pass in a JSON with additional fields without breaking the code.

🚅 Active Model

Active Model is a library containing various modules used in developing classes that need some features present on Active Record.

ActiveModel is a component of Ruby on Rails and serves as the foundation for ActiveRecord models used in everyday development. It provides a collection of building blocks that power various aspects of regular models. You can selectively use these building blocks to create custom classes with similar functionality.

class GithubUser
  include ActiveModel::API

  attr_accessor :login, :id, :name
end

user = GithubUser.new(JSON.parse(json))
  # => #<GithubUser:0x000000010dfbcb70
  # @attributes= #<...>,
  # @id=170034,
  # @login="djfpaagman",
  # @name="Dennis Paagman">

One major advantage is that the interface will be familiar to Rails users. Additionally, you can enhance it with various features offered by Rails models, such as validations, callbacks, or serializers.

💠 Hashie

Hashie is a collection of classes and mixins that make Ruby hashes more powerful.

Hashie is a gem that provides additional functionality on top of Ruby hashes. It offers different ways to handle data, giving you flexibility in your work.

One commonly used approach is to use Hashie::Mash, which behaves similarly to OpenStruct (as described above).

user = Hashie::Mash.new JSON.parse(json))
  # => #<Hashie::Mash id=170034 login="djfpaagman" name="Dennis Paagman">

Hashie also provides Hashie::Dash, which is more similar to dry-struct. With `Hashie::Dash`, you can define the properties your class has and mark properties as required or provide default values.

class Person < Hashie::Dash
  property :login
  property :id
  property :name
end

user = Person.new(JSON.parse(json, symbolize_names: true))
  # => #<Person id=170034 login="djfpaagman" name="Dennis Paagman">

💠 Virtus

 Attributes on Steroids for Plain Old Ruby Objects

Once upon a time, there was a gem called Virtus that offered similar features to what we’ve described so far. However, the author has stopped working on it and declared it discontinued, recommending dry-struct as its spiritual successor.

Although Virtus was once popular and is still used by many projects, I do not recommend starting to use it today.

💠 Literal

Literal provides a few tools to help you define type-checked structs, data objects and value objects, as well as a mixin for your plain old Ruby objects.

One exciting new kid on the block is the Literal gem. It’s a layer that adds types on top of the existing data structures provided by Ruby, such as Struct and Data.

At this moment it’s not entirely ready for broader usage yet, but the foundation is there and it could be

❓… what else?

There are probably many more libraries out there, if you think one should be on this list, please reach out and I’ll consider adding it!

🚀 Conclusion

When you need more structure than just a Hash, there are several options available. Personally, I prefer using dry-struct as it strikes a nice balance between simplicity, code friendliness, and functionality.

In a future article, I will conduct benchmarks to compare the different options and their relative performance.