Do you need a fast and lightweight JSON API app? It must be powerful, flexible, quick to develop and easy to deploy?
There is a simple solution for all these requirements: to use Lotus gems to build it.
Lotus is well known for full stack web applications (via lotusrb
), but all the gems of this toolkit can be used as standalone components in existing Ruby applications.
It this case we will compose together the router and the actions to create a JSON API app that returns informations for books.
Before to get started, this application will take you 15 minutes. If you are too busy to read how to build it from scratch, jump on this repository with the complete code.
Setup
We’re gonna need to create the directory with mkdir bookshelf && cd $_
a Gemfile
:
source 'https://rubygems.org'
ruby '2.2.3'
gem 'rake'
gem 'lotus-router'
gem 'lotus-controller'
gem 'redis'
gem 'puma'
group :development, :test do
gem 'pry-byebug'
end
group :test do
gem 'rspec'
gem 'rack-test'
end
..a Rakefile
:
require 'rake'
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec)
task default: :spec
Then we can bundle
and bundle exec spec --init
.
At the top of spec/spec_helper.rb
add the following lines:
require 'support/api'
require_relative '../app'
Our spec/support/api.rb
file is useful to bring rack-test
support to RSpec and to use it only where needed.
module ApiHelper
require 'rack/test'
include Rack::Test::Methods
private
def app
Bookshelf::API::Application.new
end
def response
last_response
end
end
RSpec.configure do |config|
config.include ApiHelper, type: :api
end
Let’s run bundle exec rake
to test that the setup is valid.
Testing
With the project up and running, we can write a smoke test for one endpoint.
# spec/requests/books_spec.rb
RSpec.describe "/books", type: :api do
it "is successful" do
get "/books"
expect(response.status).to eq(200)
end
end
If we run it, RSpec will complain that Bookshelf
isn’t a defined constant. That’s because we need to write the application.
This is how the final code will appear, we postpone the explanation for a minute.
require 'json'
require 'redis'
require 'lotus/router'
require 'lotus/controller'
module Bookshelf
module API
Lotus::Controller.configure do
handle_exceptions ENV['RACK_ENV'] == 'production'
default_request_format :json
default_response_format :json
prepare do
include Controllers::Authentication
accept :json
end
end
class Repository
# …
end
class Application
def initialize
@router = Lotus::Router.new(
namespace: Bookshelf::API::Controllers,
parsers: [:json],
&Proc.new { eval(File.read('config/routes.rb')) }
)
end
def call(env)
@router.call(env)
end
end
module Controllers
module Authentication
def self.included(action)
action.class_eval do
before :authenticate!
end
end
private
def authenticate!
authenticated? or halt(401)
end
def authenticated?
true
end
end
require_relative './controllers/books'
end
end
end
If we run the spec again, it will return a 404
(Not Found), because we’re still missing the route.
Edit config/routes.rb
with the following line:
get '/books', to: 'books#index'
Now we need the last component, the action:
# controllers/books.rb
module Bookshelf::API::Controllers::Books
class Index
include Lotus::Action
def call(params)
end
end
end
Now we can finally run the spec successfully
➜ bundle exec rspec spec/requests/books_spec.rb
Randomized with seed 59536
.
Finished in 0.01566 seconds (files took 0.13151 seconds to load)
1 example, 0 failures
Randomized with seed 59536
Application
With our first test passing, we can stop and have a deeper look at our application.
Lotus Configuration
The first block that we meet is the configuration for Lotus::Controller
.
We tell the framework to threat exceptions as HTTP status codes only in production mode (with handle_exceptions
). For instance, when our application is deployed and an error is raised, we want to show a generic “Server Side Error” message, rather than show the entire stack-trace. While in development and testing mode, this is useful to understand the reason behind an error.
Because we want only to deal with JSON, we specify that the fallback format for requests is JSON (default_request_format
). When we receive the generic HTTP header Accept: */*
, we consider it as a JSON request.
For responses we use default_response_format
, to send Content-Type: application/json
header.
Next to it there is the prepare
block. This is a special feature that allows us to share code and behaviours across all the classes that include Lotus::Action
. This is similar to Ruby’s Module.included
hook.
That means the accept :json
code will be executed for all the actions in our application. That restrict the access to only JSON requests. If another MIME Type is required, the application will respond with a 406 Not Acceptable
.
The other useful purpose of prepare
is to DRY our application and let to inject code in just one place. Imagine we have 50 actions and we need to authenticate them, it would be tedious to do include Authentication
for all of them. With prepare
we can include it only once, and it will propagated to all the actions.
Authentication
For the purpose of this test application, we won’t implement an authentication strategy, but only put the code in place for it.
The idea is really simple: every time an action includes this module, it will setup a callback for the private method #authenticate!
. It verifies if the request is allowed via #authenticated?
, if not it will stop the execution (via #halt
) and returns a 401 Unauthorized
.
Again, this mechanism is enabled for all the actions, if you need to bypass this check you can override #authenticated?
in an action, by returning true
. This trick helps you to “skip” that callback.
Router
The router is at the core of our application, it will dispatch the incoming requests and parse the params from the URI and Rack env.
We initialize it as an instance variable of our application, by specifying a few options.
We the Ruby namespace where to find the actions. In the routes file we defined the action with the "books#index"
syntax. It is translated to Books::Index
, which will be searched under the Bookshelf::API::Controllers
module.
The next setting is to tell the router to parse the body of non-GET requests and make them available as params. For instance, if we receive a JSON payload in the body {"book”:”…”}
, it we can access that data via params[:book]
.
Lotus::Router
accepts a block to that let us to define the routes. Now if we have too many entries, it’s convenient to keep them in a separated file. In our case it’s config/routes.rb
, we’re reading the content and wrapping into a Proc
.
Expanding The Feature
Until now, our application isn’t interesting. It’s able to return a successful response, but nothing more.
Let’s say we want to return a bunch of data for our /books
endpoint.
# spec/requests/book_spec.rb
RSpec.describe "/books", type: :api do
before do
repository = Bookshelf::API::Repository.new
repository.clear
repository.create(:books, [{title: 'TDD'}, {title: 'Refactoring'}])
@body = JSON.dump(repository.all(:books))
end
it "is successful" do
get "/books"
expect(response.status).to eq(200)
expect(response.body).to eq(@body)
expect(response.headers['Content-Type']).to match('application/json')
end
end
To make it pass, we need to implement some business logic into the action:
module Bookshelf::API::Controllers::Books
class Index
include Lotus::Action
def call(params)
books = Bookshelf::API::Repository.new.all(:books)
self.body = JSON.dump(books)
end
end
end
We use Repository
to find all the books in our datastore, we dump the contents into a JSON string and pass it as body of the response.
Unit Tests
The testing strategy that we used until now exercises the full stack of involved components: we simulate HTTP requests, we go through the router, the action, we hit the database to load data and return it.
This is still really fast, because Lotus and Redis are lightweight technologies, but in the long run the testing suite can slow down if we involve other components in our stack.
To observe action behaviours in isolation, we can unit-test them.
Please note that this is completely optional: we can deploy it in production with our current tests. The following code, demonstrates a few interesting testing techniques.
# spec/controllers/books/index_spec.rb
RSpec.describe Bookshelf::API::Controllers::Books::Index do
let(:action) do
described_class.new(repository: repository)
end
let(:repository) do
object_double(Bookshelf::API::Repository.new, all: books)
end
let(:books) { [] }
let(:body) { [JSON.dump(books)] }
it "is successful" do
response = action.call({})
expect(response[0]).to eq(200)
expect(response[2]).to eq(body)
expect(response[1]['Content-Type']).to match('application/json')
end
it "consider */* requests as JSON" do
response = action.call({"HTTP_ACCEPT" => "*/*"})
expect(response[0]).to eq(200)
expect(response[1]['Content-Type']).to match('application/json')
end
it "rejects text/xml requests" do
response = action.call({"HTTP_ACCEPT" => "text/xml"})
expect(response[0]).to eq(406)
expect(response[2]).to eq(["Not Acceptable"])
end
end
In order to avoid to hit the database, we use a fake repository to return in memory data. Because actions are objects, we can use Dependency Injection to pass this collaborator to the constructor.
We have three examples, one to verify a successful base scenario and two for MIME Types acceptance. Look at how it’s simple to unit test an action. We instantiate it, invoke #call
and assert that the returned Rack response has the informations that we’re looking about.
There isn’t need of special glue code with RSpec (no lotus-rspec
gem). Lotus exposes objects as framework boundary, and because RSpec knows how to test objects, we’re already up and running.
We need to change the production code to make these tests to pass:
# controllers/books.rb
module Bookshelf::API::Controllers::Books
class Index
include Lotus::Action
def initialize(repository: Bookshelf::API::Repository.new)
@repository = repository
end
def call(params)
self.body = JSON.dump(
@repository.all(:books)
)
end
end
end
We can now finally run the entire test suite.
➜ bundle exec rake
# …
Randomized with seed 22851
....
Finished in 0.0228 seconds (files took 0.1566 seconds to load)
4 examples, 0 failures
Randomized with seed 22851
Server
In order to let our server to run, we need to add a config.ru
file at the root of the project. This will make it compatible with Rack.
require 'bundler/setup'
require_relative './app'
run Bookshelf::API::Application.new
To start it:
➜ bundle exec rackup
Puma 2.15.3 starting...
* Min threads: 0, max threads: 16
* Environment: development
* Listening on tcp://localhost:9292
Conclusion
We have built a lightweight JSON API app in a few minutes, by using Lotus.
For reference, when we start our app in production mode, the memory footprint is 21.6 Mb
. If we translate into a Sinatra app, it’s 21.4 Mb
. They perform more or less the same: ~1600 req/s.
Next Steps
For advanced features such as cookies, sessions, HTTP caching (Cache Control, Expires and Conditional GET), streamed responses, RESTful resources, redirects, params validations and coercions, etc.. please have a look at the Lotus guides.
For deployment instructions, please refer to the README
of the example application.
Related articles: