SoberSwag

Ruby Test Status Linters Status

SoberSwag is a combination of Dry-Types and Swagger that makes your Rails APIs more awesome. Other tools generate documentation from a DSL. This generates documentation from types, which (conveniently) also lets you get supercharged strong-params-on-steroids.

An introductory presentation is available here.

Further documentation on using the gem is available in the docs/ directory:

Types for a fully-automated API

SoberSwag lets you type your API using describe blocks. In any controller that includes SoberSwag::Controller, you get access to the super-cool DSL method define. This lets you type your API endpoint:

class PeopleController < ApplicationController
  include SoberSwag::Controller

  define :patch, :update, '/people/{id}' do
    summary 'Update a Person record.'
    description <<~MARKDOWN
      You can use this endpoint to update a Person record. Note that age cannot
      be a negative integer.
    MARKDOWN

    query_params do
      attribute? :include_extra_info, Types::Params::Bool
    end
    request_body do
      attribute? :name, Types::Params::String
      attribute? :age, Types::Params::Integer
    end
    path_params { attribute :id, Types::Params::Integer }
  end
  def update
    # update action here
  end
end

Then we can use the information from our SoberSwag definition inside the controller method:

def update
  @person = Person.find(parsed_path.id)
  @person.update!(parsed_body.to_h)
end

No need for params.require or anything like that. You define the type of parameters you accept, and we reject anything that doesn't fit.

Rendering Swagger documentation from SoberSwag

We can also use the information from SoberSwag objects to generate Swagger documentation, available at the swagger action on this controller.

You can create the swagger action for a controller as follows:

# config/routes.rb
Rails.application.routes.draw do
  # Add a `swagger` GET endpoint to render the Swagger documentation created
  # by SoberSwag.
  resources :people do
    get :swagger, on: :collection
  end

  # Or use a concern to make it easier to enable swagger endpoints for a number
  # of controllers at once.
  concern :swaggerable do
    get :swagger, on: :collection
  end

  resources :people, concerns: :swaggerable do
    get :search, on: :collection
  end

  resources :places, only: [:index], concerns: :swaggerable
end

If you don't want the API documentation to show up in certain cases, you can use an environment variable or a check on the current Rails environment.

# config/routes.rb
Rails.application.routes.draw do
  resources :people do
    # Enable based on environment variable.
    get :swagger, on: :collection if ENV['ENABLE_SWAGGER']
    # Or just disable in production.
    get :swagger, on: :collection unless Rails.env.production?
  end
end

Typed Responses

Want to go further and type your responses too? Use SoberSwag output objects, a serializer library heavily inspired by Blueprinter

PersonOutputObject = SoberSwag::OutputObject.define do
  field :id, primitive(:Integer)
  field :name, primitive(:String).optional
  # For fields that don't map to a simple attribute on your model, you can
  # use a block.
  field :is_registered, primitive(:Bool) do |person|
    person.registered?
  end
end

Now, in your define block, you can tell us that this is the type of your response:

class PeopleController < ApplicationController
  include SoberSwag::Controller
  define :patch, :update, '/people/{id}' do
    request_body do
      attribute? :name, Types::Params::String
      attribute? :age, Types::Params::Integer
    end
    path_params { attribute :id, Types::Params::Integer }
    response(:ok, 'the updated person', PersonOutputObject)
  end
  def update
    person = Person.find(parsed_path.id)
    if person.update(parsed_body.to_h)
      respond!(:ok, person)
    else
      render json: person.errors
    end
  end
end

Support for easily typing "render the activerecord errors for me please" is (unfortunately) under development.

SoberSwag Input Objects

Input parameters (including path, query, and request body) are typed using dry-struct. You don't have to do them inline. You can define them in another file, like so:

User = SoberSwag.input_object do
  attribute :name, SoberSwag::Types::String
  # use ? if attributes are not required
  attribute? :favorite_movie, SoberSwag::Types::String
  # use .optional if attributes may be nil
  attribute :age, SoberSwag::Types::Params::Integer.optional
end

Then, in your controller, just do:

class PeopleController < ApplicationController
  include SoberSwag::Controller

  define :path, :update, '/people/{id}' do
    request_body(User)
    path_params { attribute :id, Types::Params::Integer }
    response(:ok, 'the updated person', PersonOutputObject)
  end
  def update
    # same as above!
  end
end

Under the hood, this literally just generates a subclass of Dry::Struct. We use the DSL-like method just to make working with Rails' reloading less annoying.

Nested object attributes

You can nest attributes using a block. They'll return as nested JSON objects.

User = SoberSwag.input_object do
  attribute :user_notes do
    attribute :note, SoberSwag::Types::String
  end
end

If you want to use a specific type of object within an input object, you can nest them by setting the other input object as the type of an attribute. For example, if you had a UserGroup object with various Users, you could write them like this:

User = SoberSwag.input_object do
  attribute :name, SoberSwag::Types::String
  attribute :age, SoberSwag::Types::Params::Integer.optional
end

UserGroup = SoberSwag.input_object do
  attribute :name, SoberSwag::Types::String
  attribute :users, SoberSwag::Types::Array.of(User)
end

Input and Output Object Identifiers

Both input objects and output objects accept an identifier, which is used in the Swagger Documentation to disambiguate between SoberSwag types.

User = SoberSwag.input_object do
  identifier 'User'

  attribute? :name, SoberSwag::Types::String
end
PersonOutputObject = SoberSwag::OutputObject.define do
  identifier 'PersonOutput'

  field :id, primitive(:Integer)
  field :name, primitive(:String).optional
end

You can use these to make your Swagger documentation a bit easier to follow, and it can also be useful for 'namespacing' objects if you're developing in a large application, e.g. if you had a pet store and for some reason users with cats and users with dogs were different, you could namespace it with identifier 'Dogs.User'.

Adding additional documentation

You can use the .meta attribute on a type to add additional documentation. Some keys are considered "well-known" and will be present on the swagger output. For example:

User = SoberSwag.input_object do
  attribute? :name, SoberSwag::Types::String.meta(description: <<~MARKDOWN, deprecated: true)
    The given name of the students, with strings encoded as escaped-ASCII.
    This is used by an internal Cobol microservice from 1968.
    Please use unicode_name instead unless you are that microservice.
  MARKDOWN
  attribute? :unicode_name, SoberSwag::Types::String
end

This will output the swagger you expect, with a description and a deprecated flag.

Adding Default Values

Sometimes it makes sense to specify a default value. Don't worry, we've got you covered:

QueryInput = SoberSwag.input_object do
  attribute :allow_first, SoberSwag::Types::Params::Bool.default(false) # smartly alters type-definition to establish that passing this is not required.
end

Tags

If you want to organize your API into sections, you can use tags. It's quite simple:

define :patch, :update, '/people/{id}' do
  # other cool config
  tags 'people', 'mutations', 'incurs_cost'
end

This will map to OpenAPI's tags field (naturally), and the UI codegen will automatically organize your endpoints by their tags.

Testing the validity of output objects

If you're using RSpec and want to test the validity of output objects, you can do so relatively easily.

For example, assuming that you have a UserOutputObject class for representing a User record, and you have a :user factory via FactoryBot, you can validate that the serialization works without error like so:

RSpec.describe UserOutputObject do
  describe 'serialized result' do
    subject do
      described_class.type.new(described_class.serialize(create(:user)))
    end

    it 'works with an object' do
      expect { subject }.not_to raise_error
    end
  end
end

Special Thanks

This gem is a mishmash of ideas from various sources. The biggest thanks is owed to the dry-rb project, upon which the typing of SoberSwag is based. On an API design level, much is owed to blueprinter for the serializers. The idea of a strongly-typed API came from the Haskell framework servant. Generating the swagger documentation happens via the use of a catamorphism, which I believe I first really understood thanks to this medium article by Jared Tobin.