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
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
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
Remember to migrate your database!
./bin/rails db:migrate
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
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
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
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
Taking it for a spin
Great! We're ready to try out the API. Let's start the Rails local server:
./bin/rails s
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"}
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"}]
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"}
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
Install your new dependencies:
./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:
./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:
# 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 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
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
# ...
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
andContent-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
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
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
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
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
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)