TODO List
You can try the TODO List app here: https://backendium-todolist.herokuapp.com/.
You are going to build the API backend for the TODO list app in this exercise. We have already done the front-end for you so you can see the effects of your API endpoints.
The source code can be found on Github: https://github.com/hackpacific/backendium_todolist
Requirements / Specifications
Tables / Model
Tasks
- Attributes
content
: format is stringcompleted
: format is boolean; default value is falsetimestamps
: format is datetime
- Validations
content
: must be present; maximum 200 characters
API Endpoints
GET /tasks
- Controller:
tasks
- Action:
index
- Description: return all tasks from database
POST /tasks
- Controller:
tasks
- Action:
create
- Description: create a new task based on given parameters
- Parameter: accept task object with content (i.e.
{ task: { content: "Hello"} }
)
DELETE /tasks/:id
- Controller:
tasks
- Action:
destroy
- Description: delete a task identified by its object
id
- Parameter: accept task object id (i.e.
/tasks/101234
)
PUT /tasks/:id/mark_complete
- Controller:
tasks
- Action:
mark_complete
- Description: update a task to change
completed
totrue
- Parameter: accept task object id (i.e.
/tasks/101234/mark_complete
)
PUT /tasks/:id/mark_active
- Controller:
tasks
- Action:
mark_active
- Description: update a task to change
completed
tofalse
- Parameter: accept task object id (i.e.
/tasks/101234/mark_active
)
Step-by-step Guides
Since we are not starting from scratch (instead, you are building on top of an existing repository), you can download / clone the project repo:
Before you clone, make sure you navigate to the folder you want this repository to download to.
$ git clone https://github.com/hackpacific/backendium_todolist_project.git
Cloning into 'backendium_todolist_project'...
remote: Counting objects: 107, done.
remote: Compressing objects: 100% (78/78), done.
remote: Total 107 (delta 4), reused 0 (delta 0), pack-reused 19
Receiving objects: 100% (107/107), 27.13 KiB | 0 bytes/s, done.
Resolving deltas: 100% (6/6), done.
Checking connectivity... done.
Once, you have finished cloning, use $ ls
and you should see the folder called backendium_todolist_project
which is the repository we are going to use. Navigate into backendium_todolist_project
.
Now, you can do another $ ls
to see the file structures of backendium_todolist_project
.
$ ls
Gemfile README.md app config db log spec vendor
Gemfile.lock Rakefile bin config.ru lib public tmp
The file structure should look very familiar because all Rails projects follow the same conventions and folder structures.
Can we start the server $ rails s
already? Well... try it. If it doesn't work, that's because we need to run $ bundle install
to install all the Ruby dependencies /gems listed in Gemfile
.
$ rails s
now and visit localhost:3000
. You should see the TODO list app with no tasks. If you try to add tasks, it wouldn't work because we haven't written any backend code yet.
I have written 10 tests / specs for you to follow along to build your API endpoint. Run $ rspec
.
Failures:
1) Task.new show not raise an error
Failure/Error:
expect {
Task.new
}.not_to raise_error
expected no Exception, got #<ActiveRecord::StatementInvalid: Could not find table 'tasks'> with backtrace:
...
Finished in 0.04859 seconds (files took 6.1 seconds to load)
10 examples, 10 failures
You currently have 10 specs (examples), which are all failing. Since every time we run $ rspec
, it will run all the specs, it will generate a lot of error messages if we have a lot of tests failing. So, instead of running all the tests every time, we can actually specify which tests to run.
For example, since we need to store tasks in the database, we should work with migrations and models first. If we want to run $ rspec
for models only, we can run $ rspec spec/models
.
You can imagine that
$ rspec
is the same as$ rspec spec
which will run all the test in the/spec
folder. By going to/spec/models
, we are now running less tests.
$ rspec spec/models
FFFFF
Failures:
1) Task .new should not return an error
Failure/Error:
expect {
Task.new
}.not_to raise_error
expected no Exception, got #<NameError: uninitialized constant Task> with backtrace:
...
Finished in 0.02771 seconds (files took 6.4 seconds to load)
5 examples, 5 failures
So, there are 5 failures. Let's try to tackle the first one. The test specifies that it expects no error when I run Task.new
, but that's not the case. Looking closely in the log, the error raised was #<NameError: uninitialized constant Task>
which means that it cannot find Task
. Why? Well, it's expecting the Task
model, so we have to make one.
$ rails g model task
invoke active_record
identical db/migrate/20161215092914_create_tasks.rb
create app/models/task.rb
invoke rspec
identical spec/models/task_spec.rb
invoke factory_girl
identical spec/factories/tasks.rb
By default, rails generators will also generate the Rspec file. But, since the spec file already exists, it will prompt to ask if you want to overwrite the spec file.
conflict spec/models/task_spec.rb
Overwrite /Users/harrychen/dev/test/backendium_todolist_project/spec/models/task_spec.rb? (enter "h" for help) [Ynaqdh] n
Say no to overwrite by typing n
and pressing enter.
Rails helped us generated 2 files we wanted. The migration file db/migrate/xxxx_create_tasks.rb
and the model file app/models/task.rb
In db/migrate/xxxx_create_tasks.rb
, we don't need to change anything yet:
class Tasks < ActiveRecord::Migration[5.0]
def change
create_table :tasks do |t|
t.timestamps null: false
end
end
end
In app/models/task.rb
, we can leave it as it is for now:
class Task < ApplicationRecord
end
To make sure that our tasks
table in the database takes effect. We have to run migration by doing $ rails db:migrate
. Don't believe me? You can run $ rspec spec/models
first to see what you get.
Remember? Defining the migration to modify the database schema is not enough. You have to execute / run the migrations.
By the way,
$ rspec
is your best friend when it comes to API backend development. When you have written good specs, its error messages will guide you through step-by-step on what needs to be done.
$ rails db:migrate
== 20161215092914 CreateTasks: migrating ======================================
-- create_table(:tasks)
-> 0.0015s
== 20161215092914 CreateTasks: migrated (0.0017s) =============================
Okay, now that we have run the migrations, let's test it.
$ rspec spec/models
.FFFF
Failures:
1) Task attributes should include 'content'
Failure/Error:
expect {
task.content
}.not_to raise_error
expected no Exception, got #<NoMethodError: undefined method `content' for #<Task id: nil, created_at: nil, updated_at: nil>> with backtrace:
...
Finished in 0.04048 seconds (files took 3.16 seconds to load)
5 examples, 4 failures
We've Passed First Test!.
You see
.FFFF
?.
means a pass.F
means failure.
Alright, now what's the next step? Well, our tasks
table needs to have two different columns: content
and completed
. So, we need to create a new migration to, again, modify the database schema. If you run $ rspec spec/models
, you see that my specs are expecting the same thing.
$ rspec spec/models
.FFFF
Failures:
1) Task attributes should include 'content'
Failure/Error:
expect {
task.content
}.not_to raise_error
expected no Exception, got #<NoMethodError: undefined method `content' for #<Task id: nil, created_at: nil, updated_at: nil>> with backtrace:
2) Task attributes should include 'completed'
Failure/Error:
expect {
task.completed
}.not_to raise_error
expected no Exception, got #<NoMethodError: undefined method `completed' for #<Task id: nil, created_at: nil, updated_at: nil>> with backtrace:
...
5 examples, 4 failures
So, migrations. Let's create a new migration file.
$ rails g migration AddAttributesToTasks
invoke active_record
create db/migrate/xxxx._add_attributes_to_tasks.rb
Why is it called
AddAttributesToTasks
? Because I named it to give me semantic meanings. You can name it however you want, but having semantic meaning is recommended.
In db/migrate/xxxx_add_attributes_to_tasks.rb
, let's add content
as a string and completed
as a boolean to the tasks
table. We set the default completed
to be false because tasks are not completed to begin with.
class AddAttributesToTasks < ActiveRecord::Migration[5.0]
def change
add_column :tasks, :content, :string
add_column :tasks, :completed, :boolean, default: false
end
end
Run your migration.
rails db:migrate
== 20161215094136 AddAttributesToTasks: migrating =============================
-- add_column(:tasks, :content, :string)
-> 0.0059s
-- add_column(:tasks, :completed, :boolean, {:default=>false})
-> 0.0036s
== 20161215094136 AddAttributesToTasks: migrated (0.0097s) ====================
Now, test it again.
$ rspec spec/models
...FF
Failures:
1) Task .create requires the presence of 'content'
Failure/Error:
expect {
Task.create!(content: nil)
}.to raise_error(ActiveRecord::RecordInvalid)
expected ActiveRecord::RecordInvalid but nothing was raised
2) Task .create 'content' should have max 200 chars
Failure/Error:
expect {
Task.create!(content: 'a' * 201)
}.to raise_error(ActiveRecord::RecordInvalid)
expected ActiveRecord::RecordInvalid but nothing was raised
5 examples, 2 failures
2 more tests passed!
So, how do we pass the last 2 tests for the Task model? What's the requirement. Well, here it says Task .create requires the presence of 'content' and Task .create 'content' should have max 200 chars.
We need to define some validations for the Task model.
content
must be presentcontent
must not exceed 200 characters
In app/models/task.rb
, we define these validations:
class Task < ApplicationRecord
validates :content, length: { maximum: 200 }, presence: true
end
$ rspec spec/models
should now all pass!
$ rspec spec/models
.....
5 examples, 0 failures
Awesome! So, we have successfully finish building the Task model. We can now store tasks records in the database. The M in the MVC is done. Let's move on to the V and C.
We expect to have 5 endpoints. Let's run $ rspec
to see the requirements.
$ rspec
FFFFF.....
Failures:
1) TasksController GET /tasks renders all tasks in JSON
Failure/Error: get :index
ActionController::UrlGenerationError:
No route matches {:action=>"index", :controller=>"tasks"}
...
The first endpoint GET /tasks
is missing. This endpoint is used to retrieve all the tasks in our database.
To define an endpoint, remember we always follow this flow:
- define endpoint in routes to receive requests
- create the controller (if it doesn't exist) and the methods
- use the method to process the request
- interact with the database if needed
- give back a response
So, we define the route.
In config/routes.rb
,
Rails.application.routes.draw do
...
get 'tasks' => 'tasks#index'
...
end
Test it.
$ rspec
FFFFF.....
Failures:
1) TasksController GET /tasks renders all tasks in JSON
Failure/Error: get :index
AbstractController::ActionNotFound:
The action 'index' could not be found for TasksController
...
We need to add the method. Inside the method, we are retrieving all tasks records with Task.all
in an array. We store it in an instance variable @tasks
with the @ because we want the views to be able to access this variable. We render the response with the help of jbuilder. render 'tasks/index'
points the file app/views/tasks/index.jbuilder
.
In app/controllers/tasks_controller.rb
,
class TasksController < ApplicationController
def index
@tasks = Task.all
render 'tasks/index' # can be omitted
end
end
In app/views/tasks/index.jbuilder
(you need to create this file first since it doesn't exist automatically). @tasks
is an array, we need to loop through it to output all the tasks. json.array!
helps use to do the iterations. For each iterations, we are constructing the object with id
, content
, completed
and created_at
.
json.tasks do
json.array! @tasks do |task|
json.id task.id
json.content task.content
json.completed task.completed
json.created_at task.created_at
end
end
The resulting JSON should look like the following
{
"tasks": [
{
"id": 1,
"content": "Content #1",
"completed": false,
"created_at": "2017-01-01..."
}, {
"id": 2,
"content": "Content #2",
"completed": false,
"created_at": "2017-01-01..."
}
]
}
Now, you can run $ rspec
again, and the previous test should pass. We are now down to 4 failures to resolve.
$ rspec
.FFFF.....
Failures:
1) TasksController POST /tasks renders newly created task in JSON
Failure/Error:
post :create, params: {
task: {
content: 'New Task'
}
}
ActionController::UrlGenerationError:
No route matches {:action=>"create", :controller=>"tasks", :task=>{:content=>"New Task"}}
The endpoint POST /tasks
is missing. This endpoint is used to create a new task in our database. The endpoint will expect a body like the following:
{
"task": {
"content": "Task Content"
}
}
So, let's define the route.
In config/routes.rb
,
Rails.application.routes.draw do
# ...
post 'tasks' => 'tasks#create'
# ...
end
And...define the create
method.
In app/controllers/tasks_controller.rb
,
class TasksController < ApplicationController
def index
# ...
end
def create
@task = Task.new(task_params)
if @task.save
render 'tasks/create' # can be omitted
end
end
private
def task_params
params.require(:task).permit(:content)
end
end
Let's talk about task_params
. It's the Rails way of doing things. task_params
specifies that the request parameters need to have a task
object which contains the key content
. In Rails, we write it like this params.require(:task).permit(:content)
.
@task = Task.new(task_params)
creates a new task object.
.new
is different from.create
. You have to.save
the new object when you are using.new
Once the @task
is created, we must save it to store it the database with @task.save
. @task.save
will return true
if successful, and vice versa.
So we use if @task.save
to say that if @task.save
is successful, render the jbuilder file render 'tasks/create'
.
In app/views/tasks/create.jbuilder
(you need to create this file first),
json.task do
json.id @task.id
json.content @task.content
json.completed @task.completed
json.created_at @task.created_at
end
Run $ rspec
again. Awesome.
Go to localhost:3000
(remember to start your server). You should now be able to add tasks. If you refresh the page after adding multiple tasks, these tasks should persist (stay there) since they are already in the database.
Likewise, we have to define the endpoint DELETE /tasks/:id
to destroy / delete a task in our database. The endpoint will expect a id
to identify which task to destroy.
In config/routes.rb
,
Rails.application.routes.draw do
# ...
delete 'tasks/:id' => 'tasks#destroy'
# ...
end
In app/controllers/tasks_controller.rb
,
class TasksController < ApplicationController
def index
# ...
end
def create
# ...
end
def destroy
@task = Task.find_by(id: params[:id])
if @task and @task.destroy
render json: { success: true }
else
render json: { success: false }
end
end
private
# ...
end
params[:id]
grabs theid
. For example, if the request isDELETE /tasks/101
,params[:id]
would equal to "101".Task.find_by(id: params[:id])
looks for the task with theid
if @task and @task.destroy
means that 1) if you can find the task in the database, and 2) if you can destroy it- return a json of
{ success: true }
if everything is successful
Run rspec
. Another test passed. Go to localhost:3000
. You should now be able to DELETE a task!
Alright. Two more endpoints, then we are done. We need the PUT /tasks/:id/mark_complete
endpoint to mark a task identified by the given id
as completed.
Try it yourself before seeing the answer.
In config/routes.rb
,
Rails.application.routes.draw do
# ...
put '/tasks/:id/mark_complete' => 'tasks#mark_complete'
# ...
end
In app/controllers/tasks_controller.rb
,
class TasksController < ApplicationController
def index
# ...
end
def create
# ...
end
def destroy
# ...
end
def mark_complete
@task = Task.find_by(id: params[:id])
if @task and @task.update(completed: true)
render 'tasks/mark_complete'
end
end
private
# ...
end
In app/views/tasks/mark_complete.jbuilder
,
json.task do
json.id @task.id
json.content @task.content
json.completed @task.completed
json.created_at @task.created_at
json.updated_at @task.updated_at
end
Run Rspec & Test.
And lastly, we need the PUT /tasks/:id/mark_active
endpoint to mark a task identified by the given id
as NOT completed (basically doing the opposite as the previous endpoint).
Try it yourself before seeing the answer.
In config/routes.rb
,
Rails.application.routes.draw do
# ...
put '/tasks/:id/mark_active' => 'tasks#mark_active'
# ...
end
In app/controllers/tasks_controller.rb
,
class TasksController < ApplicationController
def index
# ...
end
def create
# ...
end
def destroy
# ...
end
def mark_complete
@task = Task.find_by(id: params[:id])
if @task and @task.update(completed: true)
render 'tasks/update'
end
end
def mark_active
@task = Task.find_by(id: params[:id])
if @task and @task.update(completed: false)
render 'tasks/update'
end
end
private
# ...
end
Here, you can see that the difference between mark_active
and mark_complete
is where you updated the completed
as false
instead of true
. Everything else is the same. We also renamed app/views/tasks/mark_complete.jbuilder
to app/views/tasks/update.jbuilder
. Why? Because the two methods can share the same jbuilder view. We don't want redundant code. Remember DRY, Don't Repeat Yourself?
Now run $ rspec
$ rspec
..........
10 examples, 0 failures
All PASSED!
Now, check localhost:3000
. All functions should work now!