Generating API Docs. Is it possible? Part 2

Using JSON:API, Ruby and OpenAPI

Generating API Docs. Is it possible? Part 2

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 users
  • GET /users/:id Returns specific user
  • POST /users Creates a user
  • PATCH /users/:id Updates a user
  • DELETE /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:

image.png

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