- Handle multiple
GET
requests in a controller - Use the params hash to look up data with Active Record
- Send a JSON response using data from an Active Record model
- Use the
#to_json
method to serialize JSON data
OK, it's the moment of truth! Our application is all set up; we've reviewed the file structure and talked about how to run the server. Let's talk about how we can use Sinatra to access data about our models and send that data as a response.
Imagine this scenario: you're given the task of creating a new game review website from scratch. You want a dynamic, highly interactive frontend, so naturally you choose React. You also need to store the data about your users, your games, and the reviews somewhere. Well, it sounds like we need a database for that. Great! We can use Active Record to set up and access data from the database.
Here's the problem though. React can't communicate directly with the database — for that, you need Active Record, and Ruby. Active Record also doesn't know anything about your React application (and nor should it!). So how can we access the data we need from in React from the database?
Well, it sounds like we need some sort of interface between React and our database. Perhaps some sort of Application Programming Interface (or as you may know it, API). We need a structured way for these two applications to communicate, using a couple things they do have in common: HTTP and JSON.
That is what we'll be building for the rest of this section: an API (specifically, a JSON API) that will allow us to communicate with a database using Active Record from a React application — or really, any application that speaks HTTP that wants to interact with our database!
We'll continue building our Sinatra application using the code from the previous lesson. Run these commands to install the dependencies and set up the database:
bundle install
bundle exec rake db:migrate db:seed
Note: Running
rake db:migrate db:seed
on one line will run the migrations first, then the seed file. It's a nice way to save a few keystrokes!
You can view the models in the app/models
directory, and the migrations in the
db/migrate
folder. Here's what the relationships will look like in our ERD:
Then, run the server with our new Rake task:
bundle exec rake server
With that set up, let's work on getting Sinatra and Active Record working together!
Imagine we're building a feature in a React application where we'd like to show our users a list of all the games in the database. From React, we might have some code that looks like this to make this request for the data:
function GameList() {
const [games, setGames] = useState([]);
useEffect(() => {
fetch("http://localhost:9292/games")
.then((r) => r.json())
.then((games) => setGames(games));
}, []);
return (
<section>
{games.map((game) => (
<GameItem key={game.id} game={game} />
))}
</section>
);
}
It's now our job to set up the server so that when a GET request is made to
/games
, we return an array of all the games in our database in JSON format.
Let's set up that code in our controller:
class ApplicationController < Sinatra::Base
get '/games' do
# get all the games from the database
# return a JSON response with an array of all the game data
end
end
How do we get all the games from the database? Thankfully for us, Active Record makes it simple:
Game.all
# => [#<Game>, #<Game>, #<Game>]
We can also use Active Record's #to_json
method to convert this list of Active
Record objects to a JSON-formatted string. All together, in our controller,
here's how that would look:
class ApplicationController < Sinatra::Base
get '/games' do
# get all the games from the database
games = Game.all
# return a JSON response with an array of all the game data
games.to_json
end
end
Now head over to the browser, and visit the newly-created /games
endpoint at
http://localhost:9292/games. You should see a
response with a JSON-formatted array of all the games from the database:
[
{
"id": 1,
"title": "Banjo-Kazooie: Grunty's Revenge",
"genre": "Real-time strategy",
"platform": "Nintendo DSi",
"price": 46,
"created_at": "2021-07-19T21:55:24.266Z",
"updated_at": "2021-07-19T21:55:24.266Z"
},
{
"id": 2,
"title": "The Witcher 2: Assassins of Kings",
"genre": "Text adventure",
"platform": "Game Boy Advance",
"price": 49,
"created_at": "2021-07-19T21:55:24.298Z",
"updated_at": "2021-07-19T21:55:24.298Z"
},
...
]
Awesome!
You also have a lot of control over how this data is returned by using Active Record. For example, you could sort the games by title instead of the default sort order:
get '/games' do
games = Game.all.order(:title)
games.to_json
end
Or just return the first 10 games:
get '/games' do
games = Game.all.order(:title).limit(10)
games.to_json
end
Now that you have full control over how the server handles the response, you have the freedom to design your API as you see fit — just think about what kind of data you need for your frontend application.
Let's make one more small adjustment to the controller. By default, Sinatra sets
a response header with the Content-Type: text/html
, since in general, web
servers are used to send HTML content to browsers. Our server, however, will be
used to send JSON data, as you've seen above. We can indicate this by changing the
response header for all our routes by adding this to the controller:
class ApplicationController < Sinatra::Base
# Add this line to set the Content-Type header for all responses
set :default_content_type, 'application/json'
get '/games' do
games = Game.all.order(:title).limit(10)
games.to_json
end
end
We've got our API set up to handle one feature so far: we can return a list of all the games in the application. Let's imagine we're building another frontend feature — this time, we want a component that will just display the details about one specific game, including its associated reviews. Here's how that component might look:
function GameDetail({ gameId }) {
const [game, setGame] = useState(null);
useEffect(() => {
fetch(`http://localhost:9292/games/${gameId}`)
.then((r) => r.json())
.then((game) => setGame(game));
}, [gameId]);
if (!game) return <h2>Loading game data...</h2>;
return (
<div>
<h2>{game.title}</h2>
<p>Genre: {game.genre}</p>
<h4>Reviews</h4>
{game.reviews.map((review) => (
<div>
<h5>{review.user.name}</h5>
<p>Score: {review.score}</p>
<p>Comment: {review.comment}</p>
</div>
))}
</div>
);
}
So for this feature, we know our server needs to be able to handle a GET request
to return data about a specific game, using the ID of the game to find it in the
database. For example, a GET /games/10
should return the game with the ID of
10 from the database; and a GET /games/29
should return the game with the ID
of 29.
Let's start by adding a dynamic route to the controller to handle any of these requests:
class ApplicationController < Sinatra::Base
set :default_content_type, 'application/json'
get '/games' do
games = Game.all.order(:title).limit(10)
games.to_json
end
# use the :id syntax to create a dynamic route
get '/games/:id' do
# look up the game in the database using its ID
# send a JSON-formatted response of the game data
end
end
As we saw earlier, we can access data from the dynamic portion of the URL by
using the params hash. For example, if we make a GET request to /games/10
,
the params hash would look like this:
{ "id" => "10" }
With that in mind, what Active Record method could we use to look up a game with
a specific ID? Either .find
or .find_by
would do the trick. Let's
give it a shot:
get '/games/:id' do
# look up the game in the database using its ID
game = Game.find(params[:id])
# send a JSON-formatted response of the game data
game.to_json
end
With this code in place in the controller, try accessing the data about one game in the browser at http://localhost:9292/games/1. You should see an object like this in the response:
{
"id": 1,
"title": "Banjo-Kazooie: Grunty's Revenge",
"genre": "Real-time strategy",
"platform": "Nintendo DSi",
"price": 46,
"created_at": "2021-07-19T21:55:24.266Z",
"updated_at": "2021-07-19T21:55:24.266Z"
}
Try making requests using other game IDs as well — so long as the ID exists in the database, you'll get a response.
Right now, our server is returning information about the game, but how can we also access data about its associated models like the users and reviews? We could make another endpoint for the user and review data, and make additional requests from the frontend, but that might get messy. It would be more efficient to return this data together along with the game data in just one single response.
Let's take a look at the JSON being returned from the server. How does this Ruby code:
game = Game.find(params[:id])
game.to_json
...turn into this JSON object?
{
"id": 1,
"title": "Banjo-Kazooie: Grunty's Revenge",
"genre": "Real-time strategy",
"platform": "Nintendo DSi",
"price": 46,
"created_at": "2021-07-19T21:55:24.266Z",
"updated_at": "2021-07-19T21:55:24.266Z"
}
When we're using the #to_json
method, Active Record serializes
(converts from one format to another) the Active Record object into a JSON
object by getting a list of the model's attributes based on the column names
defined in the database table associated with the model.
Under the hood, the #to_json
method calls the #as_json
method to
generate a hash before converting it to a JSON string. Looking at the
documentation for #as_json
, you'll notice we can pass some
additional options to customize how the object is serialized. For example, we
can use the include:
option to #to_json
, which will pass it along to
#as_json
, to also include data about the associations in the response:
get '/games/:id' do
game = Game.find(params[:id])
# include associated reviews in the JSON response
game.to_json(include: :reviews)
end
This will produce the following JSON structure:
{
"id": 1,
"title": "Banjo-Kazooie: Grunty's Revenge",
"genre": "Real-time strategy",
"platform": "Nintendo DSi",
"price": 46,
"created_at": "2021-07-19T21:55:24.266Z",
"updated_at": "2021-07-19T21:55:24.266Z",
"reviews": [
{
"id": 1,
"score": 9,
"comment": "Qui dolorem dolores occaecati.",
"game_id": 1,
"created_at": "2021-07-19T21:55:24.292Z",
"updated_at": "2021-07-19T21:55:24.292Z",
"user_id": 2
},
{
"id": 2,
"score": 3,
"comment": "Omnis tempora sequi ut.",
"game_id": 1,
"created_at": "2021-07-19T21:55:24.295Z",
"updated_at": "2021-07-19T21:55:24.295Z",
"user_id": 5
}
]
}
Note that this only works because our Game
model has the correct associations
set up:
class Game < ActiveRecord::Base
has_many :reviews
has_many :users, through: :reviews
end
We can even take it a level further, and include the users associated with each review:
get '/games/:id' do
game = Game.find(params[:id])
# include associated reviews in the JSON response
game.to_json(include: { reviews: { include: :user } })
end
{
"id": 1,
"title": "Banjo-Kazooie: Grunty's Revenge",
"genre": "Real-time strategy",
"platform": "Nintendo DSi",
"price": 46,
"created_at": "2021-07-19T21:55:24.266Z",
"updated_at": "2021-07-19T21:55:24.266Z",
"reviews": [
{
"id": 1,
"score": 9,
"comment": "Qui dolorem dolores occaecati.",
"game_id": 1,
"created_at": "2021-07-19T21:55:24.292Z",
"updated_at": "2021-07-19T21:55:24.292Z",
"user_id": 2,
"user": {
"id": 2,
"name": "Miss Landon Boehm",
"created_at": "2021-07-19T21:55:24.247Z",
"updated_at": "2021-07-19T21:55:24.247Z"
}
},
{
"id": 2,
"score": 3,
"comment": "Omnis tempora sequi ut.",
"game_id": 1,
"created_at": "2021-07-19T21:55:24.295Z",
"updated_at": "2021-07-19T21:55:24.295Z",
"user_id": 5,
"user": {
"id": 5,
"name": "The Hon. Del Ruecker",
"created_at": "2021-07-19T21:55:24.252Z",
"updated_at": "2021-07-19T21:55:24.252Z"
}
}
]
}
We can also be more selective about which attributes are returned from each
model with the only
option:
get '/games/:id' do
game = Game.find(params[:id])
# include associated reviews in the JSON response
game.to_json(only: [:id, :title, :genre, :price], include: {
reviews: { only: [:comment, :score], include: {
user: { only: [:name] }
} }
})
end
{
"id": 1,
"title": "Banjo-Kazooie: Grunty's Revenge",
"genre": "Real-time strategy",
"price": 46,
"reviews": [
{
"score": 9,
"comment": "Qui dolorem dolores occaecati.",
"user": {
"name": "Miss Landon Boehm"
}
},
{
"score": 3,
"comment": "Omnis tempora sequi ut.",
"user": {
"name": "The Hon. Del Ruecker"
}
}
]
}
Needless to say, the #to_json
method has a lot of capabilities! It's very
handy when you need to structure your JSON response in a specific format based
on what data is needed on the frontend.
In this lesson, you created your very first web API! You learned how to set up multiple routes to handle different requests based on what kind of data we needed for a frontend application, and used Active Record to serialize the JSON response to include all the data needed. At their most basic levels, almost web APIs provide a way for clients, like React applications, to interact with a database and gain access to data in a structured way. Thanks to tools like Sinatra and Active Record, setting up this interface is fairly straightforward.