Generating API Docs. Is it possible? Part 2
Using JSON:API, Ruby and OpenAPI
In my previous blog post I compared slate vs swagger.io. It turns out that was not the comparison I should have been making, but instead i should have compared openAPI to API Blueprint. Comparing specifications rather than tools that can render a file with a specification.
The major difference between the two is the OpenAPI uses YAML as syntax to create a structured document that can easily be parsed, while API Blueprint uses markdown with a specification to provide structure that way but feels way more loose.
Looking at the options however, it's possible to convert OpenAPI to API Blueprint, but not the other way around. So I've decided to start with OpenAPI and go from there.
In this blog post we'll use Json API resources to generate api documentation in OpenAPI format. This allows us to share professional, nice looking documentation for others to use.
Definitions used in this post:
- OpenAPI: the one we're going to be using to create our api definition
- API Blueprint: not the one we're using
- JSON API: the specification for api requests and responses
- JSON API Resource: the ruby gem used in this project to create the api
- Model: the backbone of the api
The Specs
The specifications for JSON API are pretty straight forward and very predictable (don't you love solid specifications?)
For retrieving a single user using GET this is the structure JSON api will return:
{
"data": {
"id": "long-id",
"type": "users",
"links": {
"self": "https://fake-api.com/api/v1/users/long-id"
},
"attributes": {
"email": "",
"password": "",
"phone-number": ""
},
"relationships": {}
}
For getting multiple results, the data property is an array instead of an object.
So we'll need to convert that into open API spec as a component (since we'll want to reuse it) and setup the 200 response body along with the right headers. The request headers mime type are very important for json API and might not work as intended when not setting those headers.
The Implementation
To get started we'll need all the json api resource classes and loop through those to create GET, POST, PATCH and DELETE endpoints.
For just the User resource we'll need the following endpoints:
GET /users
Returns all usersGET /users/:id
Returns specific userPOST /users
Creates a userPATCH /users/:id
Updates a userDELETE /users/:id
Deletes a user
To make things more interesting the POST
body parameters can be very different from the PATCH
body. Since you're able to setup fields that can be set only once and not updated after. For example the user ID. But we'll get to that in another post. For now we'll keep it simple and just make sure we have something workable.
We can get a list of all the json api resource classes using this:
resources = ObjectSpace.each_object(Class).select { |klass| klass < JSONAPI::Resource}
The great thing about this json resources gem is that all attributes and relationships are defined in this class, so we can iterate trough the list and create our schema based off that. This is the schema we'll need to generate for the User:
components:
schemas:
User:
type: object
properties:
email:
type: string
format: string
password:
type: string
format: string
phone-number:
type: string
format: string
One quick thing to mention are the slugs in the url and the plurality in the paths. Ruby has some great tools built in that help you with that. Our names including namespace are something like this: Api::V1::UserResource
. So we can strip out the resource part and remove the modules to only keep the singular name. From there we'll replace the underscores with dashes for the api interface and pluralize it as well.
single_name = resource.name.demodulize.gsub('Resource', '')
slug = single_name.underscore.gsub('_', '-').pluralize(2)
For Api::V1::UserResource
this results in:
single_name: User
slug: users
To access the attributes and relationships on the resources we can use the _attributes
and _relationships
properties and iterate over those.
Now we're getting somewhere! We now have a list of resources with their attributes and relationships. The next step is to get the attribute types to create a complete picture of the response the api will give us, but those are not in the resource, but in the active record model that corresponds with the resource (because unfortunately ruby isn't a strongly typed language like java where you define every return type).
Getting the attribute types from a Resource
Since we have the 'single name' of the resource we can easily get the model using safe_constantize
since the resource class and the model share the same structure:
class Api::V1::UserResource
--> class User
model = single_name.safe_constantize
Now what we have our corresponding model, we can compare the resource attributes with the model attributes and see how they are defined in the schema like so:
model = single_name.safe_constantize
if model
resource._attributes.each do |a|
attribute_name = a[0].to_s
ca = model.attribute_names.find { |column| column == attribute_name }
column_attribute_type = model.type_for_attribute(ca).type
end
end
Putting it all together we can make an OpenAPI document for just the User resource. You'll see a bit more in the document than discussed in the blog itself, which is the structure of the JSON api response and the relationships of this specific resource. Here the address is another model that is related to the user, but it is its own class still.
An important thing we'll address in the next blog is the problem with attributes on a resource that aren't returned by an api call, but are still part of the resource. In the case of a user resource, that would be the password
attribute. Its part of the resource, but the password is obviously not returned when making an api call.
The second thing we'll dive in next are the attribute methods. There's no way to determine based what type they'll return, but we'll try and find a way around that.
Feel free to paste this in something like editor.swagger.io and see what it renders:
openapi: 3.0.1
info:
description: This is the api documentation for the Fake API
version: '0.1'
title: Wisdom Panel API
termsOfService: https://www.fake-api.com
contact:
email: bas@fake-api.com
license:
name: Apache 2.0
servers:
- url: https://fake-api.com/api/v1
description: Production server
- url: https://staging.fake-api.com/api/v1
description: Staging server
paths:
"/users":
get:
summary: Returns all User objects
responses:
'200':
description: Results from query
content:
application/vnd.api+json:
schema:
type: object
properties:
data:
type: array
items:
properties:
id:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
type:
type: string
example: users
links:
type: object
properties:
self:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76
attributes:
"$ref": "#/components/schemas/User"
relationships:
type: object
properties:
address:
type: object
properties:
links:
type: object
properties:
self:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76/relationships/address
related:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76/relationships/address
links:
type: object
properties:
first:
type: string
example: https://fake-api.com/api/v1/users?page%5Bnumber%5D=1&page%5Bsize%5D=0
next:
type: string
example: https://fake-api.com/api/v1/users?page%5Bnumber%5D=1&page%5Bsize%5D=50
last:
type: string
example: https://fake-api.com/api/v1/users?page%5Bnumber%5D=1&page%5Bsize%5D=123
'403':
description: Forbidden
'404':
description: Not Found
post:
summary: Create new User object
responses:
'201':
description: Created
content:
application/vnd.api+json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
type:
type: string
example: users
links:
type: object
properties:
self:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76
attributes:
"$ref": "#/components/schemas/User"
relationships:
type: object
properties:
address:
type: object
properties:
links:
type: object
properties:
self:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76/relationships/address
related:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76/relationships/address
requestBody:
description: The request body for User
content:
application/vnd.api+json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
type:
type: string
example: users
attributes:
"$ref": "#/components/schemas/User"
relationships:
type: object
properties:
address:
type: object
properties:
data:
type: object
properties:
type:
type: string
example: users
id:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
"/users/{UserId}":
get:
summary: Returns User by Id
parameters:
- name: UserId
in: path
description: Unique ID of User
required: true
schema:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
responses:
'200':
description: Result from query
content:
application/vnd.api+json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
type:
type: string
example: users
links:
type: object
properties:
self:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76
attributes:
"$ref": "#/components/schemas/User"
relationships:
type: object
properties:
address:
type: object
properties:
links:
type: object
properties:
self:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76/relationships/address
related:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76/relationships/address
patch:
summary: Updates User object
parameters:
- name: UserId
in: path
description: Unique ID of User
required: true
schema:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
responses:
'200':
description: Results from query
content:
application/vnd.api+json:
schema:
type: object
properties:
data:
type: array
items:
properties:
id:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
type:
type: string
example: users
links:
type: object
properties:
self:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76
attributes:
"$ref": "#/components/schemas/User"
relationships:
type: object
properties:
address:
type: object
properties:
links:
type: object
properties:
self:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76/relationships/address
related:
type: string
example: https://fake-api.com/api/v1/users/6123e90f-8ced-5dae-ae0b-e3005eaafd76/relationships/address
links:
type: object
properties:
first:
type: string
example: https://fake-api.com/api/v1/users?page%5Bnumber%5D=1&page%5Bsize%5D=0
next:
type: string
example: https://fake-api.com/api/v1/users?page%5Bnumber%5D=1&page%5Bsize%5D=50
last:
type: string
example: https://fake-api.com/api/v1/users?page%5Bnumber%5D=1&page%5Bsize%5D=123
'403':
description: Forbidden
'404':
description: Not Found
requestBody:
description: The request body for User
content:
application/vnd.api+json:
schema:
type: object
properties:
data:
type: object
properties:
id:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
type:
type: string
example: users
attributes:
"$ref": "#/components/schemas/User"
relationships:
type: object
properties:
address:
type: object
properties:
data:
type: object
properties:
type:
type: string
example: users
id:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
delete:
summary: Deletes User object
parameters:
- name: UserId
in: path
description: Unique ID of User
required: true
schema:
type: string
example: 6123e90f-8ced-5dae-ae0b-e3005eaafd76
responses:
'202':
description: Delete operation accepted, but not yet completed
'204':
description: Delete operation successful
'403':
description: Forbidden
'404':
description: Resource not found
components:
schemas:
User:
type: object
properties:
email:
type: string
format: string
password:
type: string
format: string
phone-number:
type: string
format: string