10 · Project — REST API Service¶
This capstone pulls together everything from Level 3: Sinatra routing,
ActiveRecord as a standalone ORM, and RSpec request specs with
rack-test. You'll build a small JSON REST API for managing tasks, with
full CRUD, validation, proper HTTP status codes, and a test suite that
runs with no external database server (SQLite in-memory).
What you'll build¶
A TaskAPI Sinatra app exposing:
GET /tasks— list all tasks as JSONPOST /tasks— create a task,201on success,422with error details on invalid inputGET /tasks/:id— fetch one task,404if it doesn't existPATCH /tasks/:id— update a task'sdoneflagDELETE /tasks/:id— remove a task,204on success
Project layout¶
Gemfile¶
# Gemfile
source "https://rubygems.org"
gem "sinatra"
gem "activerecord"
gem "sqlite3"
gem "rack-test"
gem "rspec"
lib/task_api.rb — the whole app¶
require 'sinatra/base'
require 'active_record'
require 'json'
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Schema.define do
create_table :tasks do |t|
t.string :title, null: false
t.boolean :done, default: false
t.timestamps
end
end
class Task < ActiveRecord::Base
validates :title, presence: true
end
class TaskAPI < Sinatra::Base
set :host_authorization, { permitted_hosts: [] }
before do
content_type :json
end
get '/tasks' do
Task.all.to_json
end
post '/tasks' do
data = JSON.parse(request.body.read) rescue {}
task = Task.new(title: data['title'])
if task.save
status 201
task.to_json
else
status 422
{ errors: task.errors.full_messages }.to_json
end
end
get '/tasks/:id' do
task = Task.find_by(id: params[:id])
halt 404, { error: "not found" }.to_json unless task
task.to_json
end
patch '/tasks/:id' do
task = Task.find_by(id: params[:id])
halt 404, { error: "not found" }.to_json unless task
data = JSON.parse(request.body.read) rescue {}
task.done = data['done'] if data.key?('done')
task.save
task.to_json
end
delete '/tasks/:id' do
task = Task.find_by(id: params[:id])
halt 404, { error: "not found" }.to_json unless task
task.destroy
status 204
end
end
A few design choices worth calling out:
before { content_type :json }sets the response header once for every route instead of repeating it five times.request.body.readwrapped in a barerescueguards against a request with an empty or malformed body —datafalls back to{}instead of the route raising a 500.halt 404, ...before continuing stops the block immediately, so the rest of the route never runs against aniltask.Task.new(title: data['title'])then.saverelies on the model's ownvalidates :title, presence: true— the route doesn't duplicate validation logic, it just checks the boolean result and reportstask.errorson failure.
spec/task_api_spec.rb — the request spec suite¶
require 'rack/test'
require_relative '../lib/task_api'
RSpec.describe TaskAPI do
include Rack::Test::Methods
def app; TaskAPI; end
before { Task.delete_all }
def post_json(path, body)
post path, body.to_json, { 'CONTENT_TYPE' => 'application/json' }
end
def patch_json(path, body)
patch path, body.to_json, { 'CONTENT_TYPE' => 'application/json' }
end
it "creates and lists tasks" do
post_json '/tasks', { title: "Write specs" }
expect(last_response.status).to eq(201)
get '/tasks'
body = JSON.parse(last_response.body)
expect(body.length).to eq(1)
expect(body[0]["title"]).to eq("Write specs")
end
it "rejects a task with no title" do
post_json '/tasks', {}
expect(last_response.status).to eq(422)
end
it "marks a task done via PATCH" do
post_json '/tasks', { title: "Ship it" }
id = JSON.parse(last_response.body)["id"]
patch_json "/tasks/#{id}", { done: true }
expect(JSON.parse(last_response.body)["done"]).to eq(true)
end
it "404s for a missing task" do
get '/tasks/999'
expect(last_response.status).to eq(404)
end
it "deletes a task" do
post_json '/tasks', { title: "Temp" }
id = JSON.parse(last_response.body)["id"]
delete "/tasks/#{id}"
expect(last_response.status).to eq(204)
get "/tasks/#{id}"
expect(last_response.status).to eq(404)
end
end
The post_json/patch_json helpers exist because rack-test's default
post/patch send application/x-www-form-urlencoded bodies — sending
a raw JSON string without an explicit CONTENT_TYPE header is a common
mismatch that makes request.body.read return valid-looking JSON that
your app never actually parses correctly on the client side of the
test (Sinatra itself doesn't care about the header for request.body,
but it's good practice to send the header your route will document as
required, and some frameworks do branch on it).
Running it¶
$ ruby spec/task_api_spec.rb
-- create_table(:tasks)
-> 0.0034s
.....
Finished in 0.02152 seconds (files took 0.42981 seconds to load)
5 examples, 0 failures
All five specs pass: list, create, validation-rejection, PATCH, and
DELETE-then-404. before { Task.delete_all } resets the in-memory table
between examples so specs don't leak state into each other — without it,
"creates and lists tasks" would see tasks left behind by earlier
examples once the suite grows.
What this project demonstrates from Level 3¶
- Module 1 (Sinatra): routes,
params,halt, modularSinatra::Basestyle (needed here specifically sorack-testcan targetTaskAPIrather than the single globalSinatra::Application). - Module 2 (ActiveRecord): a model with validations,
find_byreturningnilinstead of raising,.to_jsonfor serialization. - Module 3 (Testing Advanced): a full request-spec suite exercising the HTTP layer end-to-end rather than mocking pieces out.
How It Actually Works¶
This API's request lifecycle threads together nearly every mechanism
covered so far in Level 3: each incoming connection is handled by a Rack
#call(env) invocation on a thread from your app server's pool; route
matching walks the framework's route table exactly like Sinatra's; each
ActiveRecord query you issue builds a lazy Relation that only fires SQL
once actually iterated or rendered into JSON; and any raised exception
(a validation failure, a missing record) unwinds the stack looking for a
matching rescue — often one registered globally via an error or
rescue_from handler rather than inline in each action, but the underlying
unwind-and-match mechanism is identical to a plain begin/rescue. Because
each request typically runs on its own thread (or process, depending on
server config) with its own instance of the controller/handler object,
concurrent requests never share instance-variable state — only the
underlying database connection pool and any explicitly shared globals are
genuinely shared resources you need to reason about for thread-safety.
Stretch goals¶
- Add a
GET /tasks?done=truefilter that returns only completed tasks, with a request spec covering bothtrueandfalsevalues. - Add pagination:
GET /tasks?page=2&per_page=5, returning a JSON envelope{ "tasks": [...], "page": 2, "total": N }instead of a bare array — update every existing spec's body-parsing accordingly. - Extract the JSON error-handling boilerplate (
halt 404, { error: ... }.to_json) into a small helper method shared across routes, and add a Sinatraerror 500handler that returns{ "error": "internal server error" }.to_jsoninstead of Sinatra's default HTML error page. - Swap the in-memory SQLite database for a file-based one
(
database: "tasks.db") and add a request spec proving data survives across two separately-instantiatedTaskAPIrequests within the same process — then explain in a comment why this still wouldn't survive across separaterubyprocess runs without also removingActiveRecord::Schema.definere-runningcreate_tableon every boot (hint: usecreate_table ... unless ActiveRecord::Base.connection.table_exists?(:tasks)).