Despite an existing Swagger-compatible API, most of Strava interactions written in Ruby don’t use any auto-generated code and prefer strava-api-v3 (update: this gem was yanked in 2024), a thin client that covers the majority of the Strava API. This is a much cleaner than any swagger-generated mess, but only comes with bare minimum extras. For example, the client can retrieve an activity, but does not have any code to convert a distance from meters to miles or calculate an athlete’s pace. It will also leave you having to refer to the Strava documentation on whether distance
is in meters, yards or feet.
That seems reasonable for a thin client, but I’ve already had to copy-paste a ton of code between publishing my runs to github pages and my Strava Slack bot. I considered adding that code to the strava-api-v3 client, but found its implementation gross enough to write a brand new one.
Introducing strava-ruby-client.
Unlike strava-api-v3 provides complete OAuth refresh token flow support, a richer first class interface to Strava models, natively supports pagination and implements more consistent error handling.
The rest of this post is about implementation details.
This is not my first API client rodeo, following the very popular slack-ruby-client and a newer iex-ruby-client, so you can be sure I integrated many of the lessons learned into this work. If you’re building a Ruby client for an API, I strongly encourage you to reuse this as a boilerplate.
Basics
I always start with a README.md, LICENSE.md, CONTRIBUTING.md, CHANGELOG.md and RELEASING.md. Future updates will include UPGRADING.md.
Version
This is very straightforward, but many API clients don’t even include a version, much less announce themselves to servers properly.
module Api
VERSION = '0.1.0'.freeze
end
We are going to use this in the API’s user agent.
self.user_agent = "Client/#{Api::VERSION}"
And make sure we have a test.
require 'spec_helper'
RSpec.describe Client do
before do
Config.reset
end
context 'with defaults' do
let(:client) { Client.new }
it 'sets user-agent' do
expect(client.user_agent).to eq Config.user_agent
expect(client.user_agent).to include Api::VERSION
end
end
end
Configuration
API clients tend to want to be configurable globally or directly in a client instance. This is a pretty interesting pattern with the following configuration class.
module Api
module Config
extend self
ATTRIBUTES = %i[
endpoint
access_token
logger
proxy
timeout
].freeze
attr_accessor(*Config::ATTRIBUTES)
def reset
self.endpoint = 'https://example.com/api'
self.access_token = nil
self.logger = Api::Logger.instance
self.proxy = nil
self.timeout = 30
end
end
class << self
def configure
block_given? ? yield(Config) : Config
end
def config
Config
end
end
end
Api::Config.reset
Note that this pattern can be extended deeper, and the configuration can be further nested, as in iex-ruby-client#88.
The client itself.
module Api
class Client < Strava::Web::Client
attr_accessor(*Config::ATTRIBUTES)
def initialize(options = {})
Config::ATTRIBUTES.each do |key|
send("#{key}=", options[key] || Api.config.send(key))
end
end
end
class << self
def configure
block_given? ? yield(Config) : Config
end
def config
Config
end
end
end
What does this do? It allows global configuration.
Api::Client.configure do |config|
config.access_token = 'token'
config.logger = ::Logger.new(STDOUT)
end
And allows local configuration that overrides global configuration.
client = Api::Client.new(access_token: 'token')
HTTP
For HTTP clients I prefer to use Faraday, which allows clients to compose modules in a single pipeline, including Faraday::FlatParamsEncoder
to convert input arguments into the HTTP GET query string, FaradayMiddleware::ParseJson
to parse JSON in response or Faraday::Response::RaiseError
to raise exceptions on HTTP errors. This also allows the caller to swap the HTTP engine in the future, often desired in complex applications that want fewer ways to HTTP.
I breakup the code into Web::Connection
and Web::Request
, easily gaining global configuration for the much needed proxy
or timeout
options.
These are mixed into the client class and reuse options from Config::ATTRIBUTES
.
class Api
class Client
include Web::Connection
include Web::Request
end
end
Models
JSON API responses are parsed by Faraday into a Hash
, but I prefer first-class objects that can be extended. I like Hashie::Trash
, despite being demoniacally possessed.
class Model < Hashie::Trash
include Hashie::Extensions::IgnoreUndeclared
property 'id'
property 'created_at', transform_with: ->(v) { Time.parse(v) }
property 'widget', transform_with: ->(v) { Models::Widget.new(v) }
property 'gadgets', transform_with: ->(v) { v.map { |r| Models::Gadget.new(r) } }
end
This allows for first class, strongly typed instance properties and for future extensibility if the API adds fields or a stronger contract with Hashie::Extensions::IgnoreUndeclared
deleted.
You can also easily extend the class with additional methods.
class Activity < Hashie::Trash
property 'type'
def type_emoji
case type
when 'Run' then '🏃'
when 'Ride' then '🚴'
end
end
API Calls
A basic API GET
call.
#
# Get current user.
#
def current_user(options = {})
Models::User.new(get('current_user', options))
end
Something with parameters. I choose to use an extensible Hash
of options
, but YMMV.
#
# Get user by id.
#
# @option options [String] :id
# User id.
#
def user(options = {})
throw ArgumentError.new('Required argument :id missing') if options[:id].nil?
Models::User.new(get("users/#{options[:id]}", options.except(:id)))
end
A collection of objects.
#
# Get users.
#
def users(options = {})
get('users', options).map do |row|
Models::User.new(row)
end
end
Pagination
Clients should help with pagination. I typically roll out a cursor and wrap all collection API calls into it, enabling developers to automatically paginate through results by supplying a block. This can be seen in strava-ruby-client@40f2a0fd.
Famous Last Words
Writing clients is fun. If you are stuck with a crappy client with poor error handling or data modeling, roll your own based on strava-ruby-client in less than a day’s worth of work.