Generating an OpenAPI/Swagger spec from a Ruby on Rails API
Tags: ruby, rails, webdev, tutorial
Published: 2023-06-13 13:09:10


In this post we will go through how to generate an OpenAPI (previously Swagger) API reference from a Ruby on Rails application serving an JSON REST API.

[OpenAPI](https://www.doctave.com/blog/2023/02/21/what-is-openapi.html) has become an industry standard for describing APIs, and companies commonly publish OpenAPI specs for documentation and code generation purposes.

We will be creating a "Coffee Ordering API" using Ruby on Rails, and using a tool called [rswag](https://github.com/rswag/rswag/) to create tests that verify the behaviour of our API and generate an OpenAPI reference.

**Note:** You can view the source code for this tutorial [on GitHub](https://github.com/Doctave/ruby-on-rails-openapi-example)

## Creating the app

_(We are going to assume for the purposes of this demo that you have both Ruby and Rails installed on your machine. We recommend the [Rails Guides](https://guides.rubyonrails.org/v5.1/getting_started.html) if you need help getting started.)_

_We are using Rails 7.0.2 and Ruby 3.1.2 for this example._

First, let's create a new app! We are going to skip some best practices here in order to get something we can play with quickly.

```shell
rails new coffee_shop --api
cd coffee_shop
```

We are passing the `--api` flag to skip some code generation, as we are not going to be serving any HTML from this app, only JSON.

### Data model

Next, we will create our data model, the `Order`.

Create a migration for it:

```shell
./bin/rails g model Order kind price:integer customer
```

We can also update the migration to make sure our fields are required on the database level:

```ruby
# db/migrate/<timestamp>_create_orders.rb
class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.string :kind, null: false
      t.decimal :price, null: false
      t.string :customer, null: false

      t.timestamps
    end
  end
end
```

Remember to migrate your database!

```shell
./bin/rails db:migrate
```

Let's also add some validations into our model:

```ruby
# app/models/order.rb
class Order < ApplicationRecord

  validates :kind, presence: true
  validates :price, presence: true, numericality: { greater_than: 0 }
  validates :customer, presence: true
end
```

### Controller

Next, let's create a controller. We will put it under an `Api` namespace, since we want to prefix the API routes with `/api`.

```shell
./bin/rails g controller Api::Orders
```

Next, make sure we specify a route for this controller.

```ruby
# config/routes.rb
Rails.application.routes.draw do

  namespace :api do
    # We will only care about a couple actions in this example
    resources :orders, :orders, only: [:index, :show, :create]
  end
end
```

On to the controller! We will define:

- An `index` method for listing all orders
- A `show` method for getting the details of a single order
- A `create` method for creating a new order

```ruby
class Api::OrdersController < ApplicationController
  def index
    @orders = Order.all

    render json: @orders, except: [:created_at, :updated_at]
  end

  def show
    @order = Order.find(params[:id])

    render json: @order, except: [:created_at, :updated_at]
  rescue ActiveRecord::RecordNotFound
    render json: { error: "Order not found" }, status: :not_found
  end

  def create
    @order = Order.new(order_params)

    if @order.save
      render json: @order, except: [:created_at, :updated_at]
    else
      render json: @order.errors
    end
  end

  private

  def order_params
    params.require(:order).permit(:kind, :price, :customer)
  end
end
```

### Taking it for a spin

Great! We're ready to try out the API. Let's start the Rails local server:

```shell
./bin/rails s
```

We can now test our controller and see it in action:

```shell
curl
  -XPOST \
  -H 'Content-Type: application/json' \
  -d '{"order": {"price": "2.3", "customer": "Nik", "kind": "Espresso"}}' \
  localhost:3000/api/orders
#=> {"id":1,"kind":"Espresso","price":"2.3","customer":"Nik"}
```

It works! Lets list all the orders:

```shell
curl \
  -H 'Content-Type: application/json' \
  localhost:3000/api/orders
#=> [{"id":1,"kind":"Espresso","price":"2.3","customer":"Nik"}]
```

And what about fetching a single order?

```shell
curl \
  -H 'Content-Type: application/json' \
  localhost:3000/api/orders/1
#=> {"id":1,"kind":"Espresso","price":"2.3","customer":"Nik"}
```

Looking good. Let's move on to describing this API with OpenAPI.

## Describing REST APIs with rswag

This is where [rswag](https://github.com/rswag/rswag/) comes in. It is an extension to [rspec-rails](https://github.com/rspec/rspec-rails) for "describing and testing API operations".

The workflow is as follows:

- Create tests under `spec/requests` that describe your API
- Add OpenAPI annotations to your tests
- Run your tests to ensure the arguments and results conform to the expected shape
- Generate an OpenAPI spec once tests pass

rswag also includes a bundled version of [Swagger UI](https://github.com/swagger-api/swagger-ui) if you want to inspect your OpenAPI spec visually during testing.

### Installing rswag

First, we install rswag. Let's add them to our Gemfile:

```ruby
gem 'rswag-api'
gem 'rswag-ui'

group :development, :test do
  gem 'rspec-rails'   # Note that we also need rspec-rails
  gem 'rswag-specs'
end
```

Install your new dependencies:

```shell
./bin/bundle install
```

Next, run the installers for rswag and rspec-rails:

```
# rspec-rails
rails generate rspec:install

# rswag
./bin/rails g rswag:api:install && ./bin/rails g rswag:ui:install && RAILS_ENV=test ./bin/rails g rswag:specs:install
```

This will set up the boilerplate needed to execute our rswag tests.

### Creating the spec

Next, let's create a spec to describe our API:

```shell
./bin/rails generate rspec:swagger Api::Orders
```

This will generate a spec file for you under `spec/requests/api/orders_spec.rb`. Open it up and inspect its contents. Let's take a look at the very first test, which describes the `index` method:

```ruby
# spec/requests/api/orders_spec.rb
require 'swagger_helper'

RSpec.describe 'api/orders', type: :request do

  path '/api/orders' do

    get('list orders') do
      response(200, 'successful') do

        after do |example|
          example.metadata[:response][:content] = {
            'application/json' => {
              example: JSON.parse(response.body, symbolize_names: true)
            }
          }
        end
        run_test!
      end
    end

# ...
```

rswag's DSL tries to mirror OpenAPI's structure. We have here a `path` method, which takes a block for describing the operations under that path. In this case, we see the `get` HTTP method for this path, which is handled by the `OrdersController`'s `index` method.

This spec says that we just expect it to return a 200 response, but does not say anything about _what_ it should return. We will be changing this shortly.

### Defining common objects

A common thing to do in OpenAPI is to create [schema components](https://swagger.io/docs/specification/components/) that are reusable in your spec. This is commonly done for objects that show up in multiple places, or common errors.

In our case we are dealing with a single type of object: the `Order` model. Let's create a reusable component for it.

rswag has created a helper file for you under `spec/swagger_helper.rb`. It contains is the root of your OpenAPI specification into which the description from your specs will be injected. This is where we can describe out `order` model:

```ruby
# spec/swagger_helper.rb
require 'rails_helper'

RSpec.configure do |config|
  config.swagger_root = Rails.root.join('swagger').to_s

  config.swagger_docs = {
    'v1/swagger.yaml' => {
      openapi: '3.0.1',
      info: {
        title: 'Coffee Shop API V1',
        version: 'v1'
      },
      paths: {},
      components: {
        schemas: {
          not_found: {
            type: 'object',
            properties: {
              message: { type: :string }
            }
          },
          order: {    # << This is where we describe our order
            type: 'object',
            required: [:kind, :price, :customer],
            properties: {
              kind: {
                type: :string,
                example: "Espresso"
              },
              price: {
                type: :string,
                pattern: "^\\d*\\.?\\d*$",
                example: "1.2",
                description: "Price, formatted as a string"
              },
              customer: {
                type: :string,
                example: "Alice"
              },
            }
          },
        },
      },
      servers: [
        {
          url: 'https://{defaultHost}',
          variables: {
            defaultHost: {
              default: 'www.example.com'
            }
          }
        }
      ]
    }
  }

  config.swagger_format = :yaml
end
```

See line 23 of the example - this is where we describe our reusable component. You can also set other things in this config file, such as your API version, your endpoint URL, and other metadata.

### Describing operations

Let's take our `index` example and expand the spec that was autogenerated for us:

```ruby
# spec/requests/api/orders_spec.rb
# ...
get('List orders') do
  tags 'Orders'
  consumes 'application/json'
  produces 'application/json'
  description "List all orders in the system"

  response(200, 'successful') do
    schema type: :array, items: { "$ref" => "#/components/schemas/order" }

    let!(:order1) { Order.create(kind: "Latte", price: 2.8, customer: "Bob") }
    let!(:order2) { Order.create(kind: "Espresso", price: 0.1, customer: "Eve") }
    
    after do |example|
      content = example.metadata[:response][:content] || {}
      example_spec = {
        "application/json"=>{
          examples: {
            test_example: {
              value: JSON.parse(response.body, symbolize_names: true)
            }
          }
        }
      }
      example.metadata[:response][:content] = content.deep_merge(example_spec)
    end

    run_test!
  end
end
# ...
```

Here we've done a number of things:

- Added a tag to the operation. Tags are used to group common operations together, and tools that consume OpenAPI will use this information.
- Set the `Accepts` and `Content-Type` header requirements
- Set a description for the operation. Note that according to the OpenAPI spec you can use Markdown in any `description` field!
- We refer to the `order` shared component with a `$ref`, telling OpenAPI this is the shape of the returned object for the operation.

_(**NOTE**: rswag has a "bug" that causes response schemas to be empty in the final OpenAPI spec, unless the `produces` method is called specifying the return content type. Thank you to [this person](https://github.com/rswag/rswag/issues/559#issuecomment-1298004275) for pointing me in the right direction)._

Finally, we've taken advantage of a really cool rswag feature: automatic example generation.

When the test is run, we can take the output the operation returned and use it in our OpenAPI spec examples. In this example, we create 2 orders, which will be returned by the list operation, and serialised into our OpenAPI example.

We'll do the same for the other two operations:

```ruby
# spec/requests/api/orders_spec.rb
# continued from above...

    post('create order') do
      tags 'Orders'
      consumes 'application/json'
      produces 'application/json'
      description "Create a new order. **NOTE**: Price is set by customer! Do not go to production."

      parameter name: :order, in: :body, schema: { "$ref" => "#/components/schemas/order" }

      response(200, 'successful') do
        schema "$ref" => "#/components/schemas/order"

        let!(:order) {
          {
            kind: "Espresso",
            price: 0.2,
            customer: "Eve"
          }
        }
        
        after do |example|
          content = example.metadata[:response][:content] || {}
          example_spec = {
            "application/json"=>{
              examples: {
                test_example: {
                  value: JSON.parse(response.body, symbolize_names: true)
                }
              }
            }
          }
          example.metadata[:response][:content] = content.deep_merge(example_spec)
        end

        run_test!
      end
    end
  end

  path '/api/orders/{id}' do
    parameter name: 'id', in: :path, type: :integer, description: 'The ID for the order'

    get('show order') do
      description "Get the details for a particular order"

      produces 'application/json'

      response(200, 'successful') do
        schema "$ref" => "#/components/schemas/order"

        let(:order) { Order.create(kind: "Latte", price: 0.8, customer: "Bob") }
        let(:id) { order.id }
        
        after do |example|
          content = example.metadata[:response][:content] || {}
          example_spec = {
            "application/json"=>{
              examples: {
                test_example: {
                  value: JSON.parse(response.body, symbolize_names: true)
                }
              }
            }
          }
          example.metadata[:response][:content] = content.deep_merge(example_spec)
        end

        run_test!
      end

      response(404, 'not found') do
        schema "$ref" => "#/components/schemas/not_found"

        let(:id) { 999999999 }

        after do |example|
          content = example.metadata[:response][:content] || {}
          example_spec = {
            "application/json"=>{
              examples: {
                test_example: {
                  value: JSON.parse(response.body, symbolize_names: true)
                }
              }
            }
          }
          example.metadata[:response][:content] = content.deep_merge(example_spec)
        end

        run_test!
      end
    end
  end
end
```

Note that for the `show` action we supply two response examples: a 200 and 404 response. We could do the same for the errors that could be returned from the `create` action, but we'll leave that as an exercise for the reader.

### Running the tests

Let's run the tests! If we did everything correctly, we should see all green.

```shell
bundle exec rspec ./spec/requests/api/orders_spec.rb
# ...
# 
# Finished in 0.03847 seconds (files took 0.51792 seconds to load)
# 3 examples, 0 failures
```

Success!

Let's try changing something in our spec, and see if the test catches it. We will make a change to the shared `order` model under `spec/swagger_helper.rb` so that the customer should be an integer instead of a string and run the tests again.

```shell
bundle exec rspec ./spec/requests/api/orders_spec.rb

#   ...a gigantic stacktrace...
# 
#   2) api/orders /api/orders post successful returns a 200 response
#      Failure/Error:
#        raise UnexpectedResponse,
#              "Expected response body to match schema: #{errors.join("\n")}\n" \
#              "Response body: #{JSON.pretty_generate(JSON.parse(body))}"
# 
#      Rswag::Specs::UnexpectedResponse:
#        Expected response body to match schema: The property '#/customer' of type string did not match the following type: integer in schema ba752ce3-171a-5719-945f-62b5f5cd1cf5#
#        Response body: {
#          "id": 1,
#          "kind": "Espresso",
#          "price": "0.2",
#          "customer": "Eve"
#        }
# 
#   ...
# 
# Finished in 0.03698 seconds (files took 0.49892 seconds to load)
# 3 examples, 2 failures
# 
# Failed examples:
# 
# rspec ./spec/requests/api/orders_spec.rb:12 # api/orders /api/orders get successful returns a 200 response
# rspec ./spec/requests/api/orders_spec.rb:43 # api/orders /api/orders post successful returns a 200 response
```

I've shortened the output of rspec significantly to focus on the important parts. Rswag correctly realised that we returned a string for our `customer` field instead of an integer, like the OpenAPI spec described.

This means we can verify that our OpenAPI spec matches the actual implementation of our server automatically. If we run these checks in CI/CD, our spec should never be out of date.

### Generating the OpenAPI spec

As a final step, let's actually generate the final OpenAPI spec for this API:

```shell
SWAGGER_DRY_RUN=0 RAILS_ENV=test ./bin/rails rswag

# Generating Swagger docs ...
# Swagger doc generated at ./coffee_shop/swagger/v1/swagger.yaml
# 
# Finished in 0.06065 seconds (files took 0.504 seconds to load)
# 3 examples, 0 failures
```

rswag just generated our OpenAPI file under `swagger/v1/swagger.yml`. Let's take
a look:

```yaml
---
openapi: 3.0.1
info:
  title: Coffee Shop API V1
  version: v1
paths:
  "/api/orders":
    get:
      summary: List orders
      tags:
      - Orders
      description: List all orders in the system
      responses:
        '200':
          description: successful
          content:
            application/json:
              examples:
                test_example:
                  value:
                  - id: 1
                    kind: Latte
                    price: '2.8'
                    customer: Bob
                  - id: 2
                    kind: Espresso
                    price: '0.1'
                    customer: Eve
    post:
      summary: create order
      tags:
      - Orders
      description: 'Create a new order. **NOTE**: Price is set by customer! Do not
        go to production.'
      parameters: []
      responses:
        '200':
          description: successful
          content:
            application/json:
              examples:
                test_example:
                  value:
                    id: 1
                    kind: Espresso
                    price: '0.2'
                    customer: Eve
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/order"
  "/api/orders/{id}":
    parameters:
    - name: id
      in: path
      description: The ID for the order
      required: true
      schema:
        type: integer
    get:
      summary: show order
      description: Get the details for a particular order
      responses:
        '200':
          description: successful
          content:
            application/json:
              examples:
                test_example:
                  value:
                    id: 1
                    kind: Latte
                    price: '0.8'
                    customer: Bob
        '404':
          description: not found
          content:
            application/json:
              examples:
                test_example:
                  value:
                    error: Order not found
components:
  schemas:
    not_found:
      type: object
      properties:
        error:
          type: string
    order:
      type: object
      required:
      - kind
      - price
      - customer
      properties:
        kind:
          type: string
          example: Espresso
        price:
          type: string
          pattern: "^\\d*\\.?\\d*$"
          example: '1.2'
          description: Price, formatted as a string
        customer:
          type: string
          example: Alice
servers:
- url: https://{defaultHost}
  variables:
    defaultHost:
      default: www.example.com
```

There we have it! An OpenAPI spec, generated automatically from our code, verified by tests to match our actual implementation. This spec can now be used to generate client SDKs or API reference documentation.

## Recap

So, we now have:

- A JSON API with 3 actions
- Tests that verify the implementation
- Automatically generated OpenAPI specifications

If you are using Ruby on Rails, this can be a very effective way to produce accurate OpenAPI specifications.

That being said, there is no free lunch. Especially when producing API documentation from OpenAPI, you have to put effort into adding helpful descriptions and lots of examples for your operations. Users won't find a bare bones OpenAPI reference useful. But a well maintained and annotated one can make an API shine.

But with a setup like this, you are well on your way to using OpenAPI effectively!