Sunstone

Sunstone is an ActiveRecord adapter for querying APIs that conform to StandardAPI.

It lets you talk to a remote HTTP API using the ActiveRecord query interface you already know — where, order, limit, find, associations, and persistence (create/update/destroy) — while Sunstone translates those calls into StandardAPI HTTP requests instead of SQL.

Requirements

  • Ruby >= 3.3
  • ActiveRecord >= 8.0.1 (Rails 8.0 and 8.1)

Installation

Add sunstone to your Gemfile:

gem 'sunstone'

Configuration

Rails

Configure the connection in config/database.yml:

development:
  adapter: sunstone
  url: https://my-api-key@mystanda.rd/api

The connection accepts the following options:

Option Description
url Sets host, port, use_ssl, and (from userinfo) api_key
host API host (defaults to 127.0.0.1)
port API port (defaults to 80, or 443 when use_ssl)
use_ssl Connect over HTTPS (inferred from an https:// url)
api_key Token sent in the Api-Key request header
user_agent String prepended to Sunstone's own User-Agent
ca_cert Path to a CA certificate file (used when use_ssl is set)

url is a convenience that fills in the others, so this:

  url: https://my-api-key@mystanda.rd:443/api

is equivalent to:

  host: mystanda.rd
  port: 443
  use_ssl: true
  api_key: my-api-key

Standalone ActiveRecord

Establish the connection on ActiveRecord::Base (or your abstract model, e.g. ApplicationRecord):

ActiveRecord::Base.establish_connection(
  adapter: 'sunstone',
  url: 'https://my-api-key@mystanda.rd/api'
)

Usage

The models below are used throughout the examples:

class Fleet < ApplicationRecord
  has_many :ships
end

class Ship < ApplicationRecord
  belongs_to :fleet
  has_and_belongs_to_many :sailors
end

Querying

Ship.find(42)                 # where: {id: 42}, limit: 1
Ship.all                      # GET /ships
Ship.where(id: 10)            # where: {id: 10}
Ship.where(fleet_id: nil)     # where: {fleet_id: nil}
Ship.where(id: [10, 12])      # id IN (10, 12)
Ship.order(:id)               # order: [{id: :asc}]
Ship.limit(5000)              # limit: 5000

find_each pages through the collection automatically:

Ship.find_each { |ship| ... } # GET /ships in batches with limit/offset

Arel predicates are supported for comparisons and boolean logic:

Ship.where(Ship.arel_table[:id].gt(10))
Ship.where(Ship.arel_table[:id].eq(10).or(Ship.arel_table[:name].eq('name')))

You can inspect the query a relation will send:

Ship.where(id: 10).to_sql
# => "SELECT ships.* FROM ships WHERE ships.id = 10"

Querying across associations

Pass a nested hash to filter on an associated resource:

Ship.where(fleet: { id: 1 })              # belongs_to
Fleet.where(ships: { id: 1 })             # has_many
Ship.where(sailors: { id: 1 })            # has_and_belongs_to_many
Fleet.where(ships: { sailors: { id: 1 } }) # nested

Aggregations

Ship.count          # select: [{count: "*"}]  (GET /ships/calculate)
Ship.count(:id)     # select: [{count: "id"}]
Ship.sum(:weight)   # select: [{sum: "weight"}]
Ship.distinct       # distinct: true

Eager loading

eager_load loads associations in a single request; preload issues a separate request per association:

Fleet.eager_load(ships: :sailors)  # one request, nested payload
Fleet.preload(ships: :sailors)     # a request per association

Persistence

fleet = Fleet.create(name: 'Armada Uno')   # POST   /fleets
ship  = Ship.find(1)
ship.update(name: 'Definant')              # PATCH  /ships/1
ship.destroy                               # DELETE /ships/1

Validation errors returned by the API (e.g. a 400 response) are written back onto the record:

fleet = Fleet.create(name: nil)
fleet.errors[:name] # => [...]  populated from the API response

Associations can be written through the parent, matching ActiveRecord's nested attributes and *_ids conventions:

# belongs_to — creates the fleet alongside the ship
Ship.create(name: 'Definant', fleet: Fleet.new(name: 'Armada Duo'))

# has_many — assign by id, by records, or clear
Fleet.create(name: 'Spanish Armada', ship_ids: [2])
fleet.update(ships: fleet.ships + [Ship.find(3)])
fleet.update(ships: [])   # clears the relation

# has_and_belongs_to_many
ship.update(sailors: [Sailor.find(1)])

RPC

rpc declares a custom, non-CRUD action that maps to a POST on a member endpoint. Any attributes the API returns are written back onto the record:

class Ship < ApplicationRecord
  rpc :self_destruct
end

ship.self_destruct!   # POST /ships/:id/self_destruct

Column types

Beyond the standard ActiveRecord types, the adapter registers:

  • array — JSON-serialized arrays of any subtype
  • json
  • uuid — validated and cast as a UUID
  • binary — Base64-encoded over the wire
  • datetime — serialized as ISO 8601

Geospatial (GIS) support is opt-in. Require it to register the geometry / geography (EWKB) types, which use RGeo:

require 'sunstone/gis'

License

Sunstone is released under the MIT License.

Changelog

See CHANGELOG.md.