DEV Community

Cover image for Generating an OpenAPI/Swagger spec from a Ruby on Rails API
Nik Begley for Doctave

Posted on • Originally published at doctave.com

Generating an OpenAPI/Swagger spec from a Ruby on Rails API

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 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 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

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 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.

rails new coffee_shop --api
cd coffee_shop
Enter fullscreen mode Exit fullscreen mode

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:

./bin/rails g model Order kind price:integer customer
Enter fullscreen mode Exit fullscreen mode

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

# 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
Enter fullscreen mode Exit fullscreen mode

Remember to migrate your database!

./bin/rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Let's also add some validations into our model:

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

  validates :kind, presence: true
  validates :price, presence: true, numericality: { greater_than: 0 }
  validates :customer, presence: true
end
Enter fullscreen mode Exit fullscreen mode

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.

./bin/rails g controller Api::Orders
Enter fullscreen mode Exit fullscreen mode

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

# 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
Enter fullscreen mode Exit fullscreen mode

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
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
Enter fullscreen mode Exit fullscreen mode

Taking it for a spin

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

./bin/rails s
Enter fullscreen mode Exit fullscreen mode

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

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"}
Enter fullscreen mode Exit fullscreen mode

It works! Lets list all the orders:

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

And what about fetching a single order?

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

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

Describing REST APIs with rswag

This is where rswag comes in. It is an extension to 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 if you want to inspect your OpenAPI spec visually during testing.

Installing rswag

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

gem 'rswag-api'
gem 'rswag-ui'

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

Install your new dependencies:

./bin/bundle install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

./bin/rails generate rspec:swagger Api::Orders
Enter fullscreen mode Exit fullscreen mode

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:

# 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

# ...
Enter fullscreen mode Exit fullscreen mode

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 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:

# 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
Enter fullscreen mode Exit fullscreen mode

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:

# 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
# ...
Enter fullscreen mode Exit fullscreen mode

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 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:

# 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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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

---
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
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)