JSON:API On Rails (Part 2 of 2)

August 12, 2019

Today we’re going to dive right back into building our app. If you need a refresher, check out part 1. We left off with the ability to CRUD Projects, TodoLists, and Todos. This is the common “boilerplate” that is often the foundation of APIs. Where APIs get more interesting though is in how they handle more complex topics such as searching, pagination, and nested writes. JSON:API has some wonderful answers to these things.

Filtering

Filtering is one of the most common problems when building out your APIs. There are lots of ways to do this, but let’s see how to do this with Graphiti. Let’s add the ability to filter projects by status. As usual, we begin with our unit test.

context "by status" do
  before do
    project1.update!(status: 'active')
    project2.update!(status: 'archived')
    params[:filter] = { status: { eq: 'active' } }
  end

  it "returns the expected projects" do
    render
    expect(jsonapi_data.map(&:id)).to eq([project1.id])
  end
end

And if we run our tests we should see this fail.

spring rspec spec/resources/project/reads_spec.rb
Running via Spring preloader in process 81715
......

Finished in 0.21146 seconds (files took 0.2209 seconds to load)
6 examples, 0 failure

Wait a minute…why is our test passing? It’s because Graphiti give us filtering for ALL of our defined attributes out of the box! This is pretty darn sweet. Something to call out here though is the structure of our filter. We start with an attribute name and then define the type of filter and pass the value. In this case status is the attribute, eq is the type, and active is our value. What if we needed to extend this though so we could filter by multiple statuses? Let’s add a test.

context "by multiple status" do
  before do
    project1.update!(status: 'active')
    project2.update!(status: 'archived')
    project3.touch
    params[:filter] = { status: { eq: 'active,archived' } }
  end

  let!(:project3) { create(:project, status: "junk") }

  it "returns the expected projects" do
    render
    expect(jsonapi_data.map(&:id)).to eq([project1.id, project2.id])
  end
end

Then we run our tests.

spring rspec spec/resources/project/reads_spec.rb
Running via Spring preloader in process 81842
.......

Finished in 0.20526 seconds (files took 0.21959 seconds to load)
7 examples, 0 failures

Man! Looks like this was handled for us already too. Graphiti gives you a lot of power. I won’t go into all the details, but you can read more here. What if we want a more complex filter though? Such as a filter that only returns projects that have a todo list? As usual, let’s start off with a test.

context "having at least 1 todo list" do
  before do
    todo_list.touch
    params[:filter] = { has_todo_lists: true }
  end

  let!(:todo_list) { create(:todo_list, project: project1) }

  it "returns the expected projects" do
    render
    expect(jsonapi_data.map(&:id)).to eq([project1.id])
  end
end

Let’s run it.

spring rspec spec/resources/project/reads_spec.rb
Running via Spring preloader in process 87453
.....F..

Failures:

  1) ProjectResource filtering having at least 1 todo list returns the expected projects
     Failure/Error: render

     Graphiti::Errors::UnknownAttribute:
       ProjectResource: Tried to filter on attribute :has_todo_lists, but could not find an attribute with that name.

Uh oh, looks like we have a problem. That’s ok though because we didn’t expect a custom filter to just work “out of the box”. Let’s fix this.

# app/resources/project_resource.rb

filter :has_todo_lists, :boolean do
    eq do |scope, value|
      if value
        scope.joins(:todo_lists)
      else
        scope.left_joins(:todo_lists).
          where("todo_lists.id is null")
      end
    end
  end

And we run our tests…

spring rspec spec/resources/project/reads_spec.rb
Running via Spring preloader in process 87559
........

Finished in 0.27244 seconds (files took 0.20924 seconds to load)
8 examples, 0 failures

Huzzah! We have our custom filter. All we had to do was define a custom filter and then whittle down the scope as needed. Not to shabby.

Pagination

Pagination (like filtering) is a somewhat interesting topic in the JSON:API world. Neither have a spec-enforced implementation. Since we have been using Graphiti though, let’s see what that library provides.

http://localhost:3000/api/v1/projects?page[size]=1&page[number]=1

It really is just that simple. In order to paginate across a resource all you need to do is pass page size and page number. Size is the number of records per “page”, and number is the desired page number. There is one small gotcha here. Let’s say our initial request to the projects endpoint looked like this:

http://localhost:3000/api/v1/projects?page[size]=1&page[number]=1

This allows us to grab the first page, but we don’t see how many pages there are. If we wanted to build client side pagination, we would be scratching our chins right about now. Have no fear though there is a way! We just have to add a little secret sauce to our request.

http://localhost:3000/api/v1/projects?page[number]=1&page[size]=1&stats[total]=count

The trick here is the stats[total]=count on the end of the request. This tells Graphiti to return the total number of records. As you might expect it does respect filters. The response looks a little something like below.

"meta": {
    "stats": {
      "total": {
        "count": 3
      }
    }
  }

Now we know how to use Graphiti to filter and paginate across data sets! But we need more from our API. We need it to be performant and not require many requests to create or fetch larger data sets. Let’s see how JSON:API and Graphiti handle that.

Nested Writes (side-posting)

If you are familiar with REST, you know one of the biggest downsides is that you can only manipulate one resource at a time. Using an API does not have to be that painful. Imagine we wanted to create a todo list with todo items for a project. But the kicker is we want to do it in just one request.

We would still do a post to the todo lists endpoint.

POST http://localhost/api/v1/todo_lists

But when we constructed the “body” of the POST request, we need to do a couple of special things. First, under the relationships section we need to specify our three todo list items. The 2 unique things here are the attributes “temp-id” and “method”. The temp-id is just a placeholder that uniquely identifies an object. The method parameter is what tells Graphiti what to do to the relationship. The available options are “create”, “update”, “disassociate”, and “destroy”.

To pass the details of these records over we need to create an included section. This section lives at the same level as data. Referencing the JSON below you will see that we reuse the content from the todo_list_items section under relationships. But now we can use attributes to pass over the details of our new todos.

{
  "data": {
    "attributes": {
      "name": "Villians to Smite"
    },
    "relationships": {
      "project": {
        "data": {
          "id": 1,
          "type": "projects"
        }
      },
      "todo_list_items": {
        "data": [
          {
            "temp-id": "111",
            "type": "todo_list_items",
            "method": "create"
          },
          {
            "temp-id": "222",
            "type": "todo_list_items",
            "method": "create"
          },
          {
            "temp-id": "333",
            "type": "todo_list_items",
            "method": "create"
          }
        ]
      }
    },
    "type": "todo_lists"
  },
  "included": [
    {
      "attributes": {
        "content": "write a blog post"
      },
      "temp-id": "111",
      "type": "todo_list_items"
    },
    {
      "attributes": {
        "content": "edit it"
      },
      "temp-id": "222",
      "type": "todo_list_items"
    },
    {
      "attributes": {
        "content": "publish it"
      },
      "temp-id": "333",
      "type": "todo_list_items"
    }
  ]
}

This really only scratches the surface of what is possible with JSON:API and Graphiti. You can read more here about all the cool things you can do with just one request to your next API.

Querying Data Graphs

There is just one last thing I would like to talk to you all about today. With GraphQL all the rage and REST looking pretty dusty it is awfully tempting to just go with the trend and use GraphQL. Don’t just jump on the bandwagon though! JSON:API gives you power to query data really expressively. But don’t just take my word for it. Let’s look at a rather complex example. Imagine that we wanted to get the entire object graph.

http://localhost/api/v1/projects?include=todo_lists,todo_lists.todo_list_items

The response we get should look like:

{
  "data": [
    {
      "id": "2",
      "type": "projects",
      "attributes": {
        "name": "Project 2",
        "description": "Something cool",
        "purpose": "To organize the project",
        "status": "active"
      },
      "relationships": {
        "todo_lists": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_lists?filter[project_id]=2"
          },
          "data": []
        },
        "todo_list_items": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_list_items?filter[project_id]=2"
          }
        }
      }
    },
    {
      "id": "3",
      "type": "projects",
      "attributes": {
        "name": "Project 3",
        "description": "Something cool",
        "purpose": "To organize the project",
        "status": "active"
      },
      "relationships": {
        "todo_lists": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_lists?filter[project_id]=3"
          },
          "data": [
            {
              "type": "todo_lists",
              "id": "1"
            },
            {
              "type": "todo_lists",
              "id": "2"
            }
          ]
        },
        "todo_list_items": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_list_items?filter[project_id]=3"
          }
        }
      }
    },
    {
      "id": "1",
      "type": "projects",
      "attributes": {
        "name": "updated name",
        "description": "Something cool",
        "purpose": "To organize the project",
        "status": "active"
      },
      "relationships": {
        "todo_lists": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_lists?filter[project_id]=1"
          },
          "data": [
            {
              "type": "todo_lists",
              "id": "4"
            },
            {
              "type": "todo_lists",
              "id": "5"
            }
          ]
        },
        "todo_list_items": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_list_items?filter[project_id]=1"
          }
        }
      }
    }
  ],
  "included": [
    {
      "id": "1",
      "type": "todo_lists",
      "attributes": {
        "name": "new name"
      },
      "relationships": {
        "project": {
          "links": {
            "related": "http://localhost:3000/api/v1/projects/3"
          }
        },
        "todo_list_items": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_list_items?filter[todo_list_id]=1"
          },
          "data": [
            {
              "type": "todo_list_items",
              "id": "6"
            }
          ]
        }
      }
    },
    {
      "id": "2",
      "type": "todo_lists",
      "attributes": {
        "name": "Hop Rod Rye 2"
      },
      "relationships": {
        "project": {
          "links": {
            "related": "http://localhost:3000/api/v1/projects/3"
          }
        },
        "todo_list_items": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_list_items?filter[todo_list_id]=2"
          },
          "data": []
        }
      }
    },
    {
      "id": "4",
      "type": "todo_lists",
      "attributes": {
        "name": "Villians to Smite"
      },
      "relationships": {
        "project": {
          "links": {
            "related": "http://localhost:3000/api/v1/projects/1"
          }
        },
        "todo_list_items": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_list_items?filter[todo_list_id]=4"
          },
          "data": [
            {
              "type": "todo_list_items",
              "id": "1"
            }
          ]
        }
      }
    },
    {
      "id": "5",
      "type": "todo_lists",
      "attributes": {
        "name": "Villians to Smite"
      },
      "relationships": {
        "project": {
          "links": {
            "related": "http://localhost:3000/api/v1/projects/1"
          }
        },
        "todo_list_items": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_list_items?filter[todo_list_id]=5"
          },
          "data": [
            {
              "type": "todo_list_items",
              "id": "2"
            },
            {
              "type": "todo_list_items",
              "id": "4"
            },
            {
              "type": "todo_list_items",
              "id": "5"
            }
          ]
        }
      }
    },
    {
      "id": "6",
      "type": "todo_list_items",
      "attributes": {
        "content": "do the dishes",
        "complete": false
      },
      "relationships": {
        "todo_list": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_lists/1"
          }
        }
      }
    },
    {
      "id": "1",
      "type": "todo_list_items",
      "attributes": {
        "content": "break the dishes!",
        "complete": false
      },
      "relationships": {
        "todo_list": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_lists/4"
          }
        }
      }
    },
    {
      "id": "2",
      "type": "todo_list_items",
      "attributes": {
        "content": "write a blog post",
        "complete": false
      },
      "relationships": {
        "todo_list": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_lists/5"
          }
        }
      }
    },
    {
      "id": "4",
      "type": "todo_list_items",
      "attributes": {
        "content": "edit it",
        "complete": false
      },
      "relationships": {
        "todo_list": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_lists/5"
          }
        }
      }
    },
    {
      "id": "5",
      "type": "todo_list_items",
      "attributes": {
        "content": "publish it",
        "complete": false
      },
      "relationships": {
        "todo_list": {
          "links": {
            "related": "http://localhost:3000/api/v1/todo_lists/5"
          }
        }
      }
    }
  ],
  "meta": {}
}

Boom! Just like that we just returned all of our projects, with their todo lists, and todo items. This is called “side-loading” in Graphiti. Essentially it allows a client to request any node(s) in the object graph and dumps them into the included section if there are any that exist. There are some gotchas in here around pagination, but if you are interested you can read more here.

Wrap Up

There is so much more power like filtering “side-loaded” relationships, sorting, and more. However, this should be a nice introduction to the flexibility and power that JSON:API and Graphiti provide. Thanks again for sticking with me. If you found this content valuable please do me a kindness by tweeting about it or dropping a reaction below. If you found any grammatical errors or have questions drop a comment and I’ll address it. After all this content is for YOU and I want you to get as much value as possible from it.

Cheers!

Comments

comments powered by Disqus