Follow each step of this lab. For each step, try to do it yourself first and see if you can get it to work. If you're stumped, click the arrow by "Details" to show the hidden code (works best when this file is viewed on github).
For example:
#this is an example
def test
"asdf"
end
Whether or not you use the provided code, TYPE ALL THE CODE FOR THIS LESSON OUT YOURSELF. DO NOT PASTE. THIS WILL HELP YOU GET USED TO IT.
For a few sections, generally ones with new information, the code is not hidden.
source 'https://rubygems.org'
gem 'pg'
gem 'sinatra'
gem 'sinatra-activerecord'
gem 'json'
gem 'pry'
gem 'bcrypt'
Since we added some gems, we bundle
. Take a look at the Gemfile.lock that is generated.
$ bundle
$ less Gemfile.lock
π΄ Commit: "Gemfile + bundle"
Set up the minimum config.ru that you would need to get a modular Sinatra app to run.
config.ru
require 'sinatra/base'
# controllers
require './controllers/ApplicationController'
# routes
map('/') {
run ApplicationController
}
Create a barebones ApplicationController. What does that inherit from? Give it a '/' default get route.
controllers/ApplicationController.rb
class ApplicationController < Sinatra::Base
require 'bundler'
Bundler.require()
get '/' do
"hey cool the server runs"
end
end
Make sure your app runs.
$ bundle exec rackup
If so then....
π΄ Commit: "Basic server runs. ApplicationController + config.ru"
Create a basic erb template that has an h1 with the name of your app. Something like "Awesome to do list App!."
βͺοΈ Link up the views folder
set :views, File.expand_path('../views', File.dirname(__FILE__))
βͺοΈ Create the view
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<h1 style="border: 1px solid blue; border-radius: 10px">Awesome Item App (this h1 is in hello.erb)</h1>
</body>
</html>
βͺοΈ Render the view in your default get route
If it renders, then...
π΄ Commit: "set up views/rendered a template"
For section 5, the code is not hidden.
βͺοΈ Create a file, db/migrations.sql
to represent our database structure. Write out the SQL below EXACTLY. Do not change any capitalizations, punctuation, singular/plural, or table/column names. Notice how we are describing relations between the tables--specifically user_id REFERENCES users(id)
--that is how we know that a User has Items. That's the "relation"... we say "A User has many items." Incidentally, we also say an item belongs to a user. This is not just a PSQL or ActiveRecord thingβthat's how you generally describe relationships in your data. When you're done typing it out, open psql
. Run DROP DATABASE item;
then copy/paste the contents of the file.
CREATE DATABASE item;
\c item
CREATE TABLE users(
id SERIAL PRIMARY KEY,
username VARCHAR(32),
password VARCHAR(60)
);
CREATE TABLE items(
id SERIAL PRIMARY KEY,
title VARCHAR(255),
user_id INT REFERENCES users(id)
);
βͺοΈ Check that it was successful by
- reading every line of output carefully from the psql terminal after you paste, and
- checking out your tables:
\d users
and\d items
Similarly, you can create a seeds.sql
file you can use to insert a lot of data all at once. This is helpful if you need to clear or reset or move your database, or when you deploy an app, or when you clone an existing project, and need some data to work with during development.
βͺοΈ Create a file db/seeds.sql
with SQL for a user with your name (lol) and password.
INSERT INTO users (username, password) VALUES ('reuben', '12345');
Yes, there is a way to automate the migrations/seeds process in both Sinatra AND Rails. No, people don't always just paste it, but we will be for the remainder of this unit.
βͺοΈ Require ActiveRecord in config.ru
.
require 'sinatra/activerecord'
βͺοΈ Add the code to connect to an 'item' database in ApplicationController. Database name here must match database name in your migrations.sql file.
ActiveRecord::Base.establish_connection(
:adapter => 'postgresql',
:database => 'item'
)
If you are able to start your server...
π΄ Commit: "added ActiveRecord and migrations/seeds"
Since we're about to have several different views, but still want to have a lot of things consistent across our whole site, we're going to use partials.
βͺοΈ Create a layout.erb
template. Cut all of the code from hello.erb
and paste it into layout.erb
. In hello.erb
, which should now be empty, you'll just have an h2
saying "this is the hello template":
<h2 style="border: 1px solid brown; border-radius: 5px">This h2 is in the hello.erb<h2>
> We're using inline styles here to point out what divs are coming from what partials. DO NOT USE INLINE STYLES IN YOUR APPS.
βͺοΈ Add the yield
to the body in layout.erb
, below the <h1>
βͺοΈ For fun, and so users can have a meaningful browser history, let's send a page title from the route. Change the <title>
to include instance variable for the page name.
<title><%= @page %></title>
If you want you could also throw some text in layout.erb to help yourself remember where which text is coming from.
<small>Thanks for using this site. This "footer" is in the main
layout.erb and will appear on all pages. Β© 2018 no one.</small>
βͺοΈ ...and then set the page title as an instance variable in the route
π΄ Commit: "set up partials"
Add an Item controller with a dummy '/' index/get route and an "add item" route that renders an add item template (that we will create in a second). Don't forget to require and map it.
controllers/ItemController.rb
class ItemController < ApplicationController
# index route
get '/' do
"this is get route in ItemController"
end
# add route
get '/add' do
erb :add_item # this view will be created in the next step
end
end
config.ru
require './controllers/ItemController'
...and farther down...
map('/items') {
run ItemController
}
If you can view '/items'
then...
π΄ Commit: "Item Controller"
Create a partial form.erb
with a form that has a red border that posts to @action
and a method of @method
where the text field has a value of @value
and a placeholder of @placeholder
and the button has a value of @buttontext
.
<form style="border: 1px solid red; border-radius: 4px;" action="<%= @action %>" method="<%= @method %>">
<input type="text" name="title" value='<%= @value %>' placeholder='<%= @placeholder %>'/>
<input type="submit" name="Submit Button" value="<%= @buttontext %>" />
<p>everything in this red box is in the form partial<p>
</form>
Create the add_item
partial mentioned in the comment in the previous step. It should have only an <h2>
with the name of the page, and then below that, the following line:
<%= erb(:form) %>
Edit the Item add route to send along all the proper values for the instance variables in form.erb
and layout.erb
and make the post to an as yet nonexistent '/items'
post route.
controllers/ItemController.rb
, in the get '/add'
route, before the return:
@page = "Add Item"
@action = "/items"
@method = "POST"
@placeholder = "Enter your item!"
@value=""
@buttontext = "Add Item"
This is perhaps a bit overkill...the purpose is just to demonstrate. But make sure you have text on your button and in your input placeholder and page title. And make sure that when you click the button, it tries to POST
to /items
.
π΄ Commit: "Nested form partial"
When you clicked that button, turns out Sinatra didn't know that ditty (i.e. you haven't defined post '/' do ...etc...
), so let's teach it to Sinatra. Make an item create route that prints the form data to the terminal and just sends back: "you posted. check your terminal." Try to post something and make sure your data looks right in the terminal.
# create route
post '/add' do
# params are in a hash called params, check your terminal
# extra puts statements help you find this output amongst the very verbose terminal output
puts "HERE IS THE PARAMS---------------------------------------"
pp params
puts "---------------------------------------------------------"
"you posted. check your terminal."
end
π΄ Commit: "Items post route"
First you will need to create your model. Don't ya just love how easy it is!?
class Item < ActiveRecord::Base
end
Yep, that's it. Now see if you can figure out/google how to make your items create route do a simple insert of one item into a table with ActiveRecord. Again, it's absurdly simple. We won't worry about users
until we have full CRUD for items
, so for now, just hard-code the user_id to be 1
. After you save, send the item back as JSON.
post '/' do
pp params
# this is how you add something with ActiveRecord.
@item = Item.new
@item.title = params[:title]
@item.user_id = 1 # for now
@item.save
# hey there's a .to_json method. cool.
@item.to_json
end
Now try to use it. Try to add some items with your form.
Would be cool if it worked, but you likely get an error like "uninitialized constant Item...." What's that all about? What do you think that means. You actually read error messages right? What did we forget? Think about it! See if you can fix it!
Make sure it works by using psql
to see what's in your items table. And check out your terminal where you have bundle exec rackup
running, and see the SQL that ActiveRecord is writing for you (pretttyyyy colllorrrrss). Also, notice that in the browser, you can see in the JSON that an ID has been added. That's from the database and it means your insert was successful.
If you got JSON in the browser, and data in your items table and it all looks right...
π΄ Commit: "Item create route saves data using ActiveRecord"
3 steps:
- update item index route get all items with ActiveRecord before you render an
:item_index
template (that you're about to make). Code is very simple. Try guessing/googling. Just send back JSON while you're figuring it out. Then once you get it and it's done... - create the
:item_index
partial that includes an "item list"<h2>
and then iterates over@items
to build a<ul>
of<li>
s. Render that template from index route. - When you have a working index page, update the item create (post) route to redirect to that index page after it does the insert
# index route
get '/' do
@items = Item.all # beautiful isn't it
# @items.to_json
@page = "Index of items"
erb :item_index
end
<h2 style="border: 1px solid purple; border-radius: 5px">Item index</h2>
<ul>
<% @items.each do |item| %>
<li><%= item.title %></li>
<% end %>
</ul>
# @item.to_json # we will come back to this
redirect '/items'
Again, look at the terminal to see the SQL that's being written for you.
π΄ Commit "Item index page"
Add a nav. If you want you can also add a site-wide title.
<nav>
<!-- REMEMBER: DO NOT USE INLINE STYLES. -->
<p style="display: inline-block;">Nav:</p>
<a href="/items">Item list</a> β’
<a href="/items/add">Add Items</a>
</nav>
<h1 id="app-name">Awesome Site!</h1>
(also, now, if you want, you can remove the footer, whose only purpose was to have some viewable content coming from the layout.erb
container template)
π΄ Commit: "Added a nav"
3 steps:
- Add the MethodOverride middleware (sound familiar??) (also, shown below)
- Make each
<li>
in your index a form. The form willDELETE
byPOST
ing and including a parameter_method
set toDELETE
, similarly to Express. However, this time, use<input type='hidden'>
to do it instead of adding it in the query string. How will you know it's working? (Click below to show answer)
- So then go ahead and write the item delete route. See if you can figure out/google how to do it. Again, ActiveRecord--very simple. You could probably get it just by guessing. Remember to redirect to index so user can see that the delete was successful.
use Rack::MethodOverride # we "use" middleware in Rack-based libraries/frameworks
set :method_override, true
<ul>
<% @items.each do |item| %>
<form action="/items/<%= item.id %>" method="POST">
<input type="hidden" name="_method" value="DELETE" />
<li><%= item.title %></li>
<button>Delete</button>
</form>
<% end %>
</ul>
delete '/:id' do
# there are many ways to do this find statement, this is just one
# remember you can play around with ActiveRecord by adding binding.pry
# and trying stuff out
@item = Item.find params[:id]
@item.destroy
redirect '/items'
end
Again, look at the SQL that's being generated for you. If you can delete, then....
π΄ Commit: "Item delete functionality"
Add a public folder for CSS. Move all your styles there where they belong because since this isn't 1995, we separate content and presentation/formatting/layout/design.
Steps:
- set it up on the server
- link it up in the template
- make a style to be sure it's working
set :public_dir, File.expand_path('../public', File.dirname(__FILE__))
<link rel="stylesheet" type="text/css" href="/css/style.css">
body {
background-color: #f3d460;
}
Then put the styles from your html in your CSS and add classes for them, where necessary in your html. How you do all of this is up to you, click below to see an example (html omitted) if you like. You can delete the extra stuff like "everything in this red box", etc.
body {
background-color: #f3d460;
}
/* from form.erb: be sure to add this class to that form while you're deleting the inline style */
.form-partial {
border: 1px solid red;
border-radius: 4px;
}
/* style the h1 in layout.erb */
h1#app-name {
border: 1px solid blue;
border-radius: 10px;
}
nav p {
display: inline-block;
}
/* from hello.erb and/or item_index.erb and/or add_item.erb */
h2 {
border: 1px solid brown;
border-radius: 5px;
}
π΄ Commit: "Set up CSS public folder and moved CSS there"
Try to create an edit functionality on your own. You have everything you need to do it at this point... you shouldn't need to google.
Don't forget: you can see the SQL that's being generated for you in your terminal.
- First create the edit link, route, and view. Make sure it works. Don't forget to override the method.
<a href="/items/edit/<%= item.id %>">(Edit)</a>
# edit route
get '/edit/:id' do
@item = Item.find params[:id]
@page = "Edit Item #{@item.id}" #why am i using interpolation here? try with concatenation and see what happens.
erb :edit_item
end
<h2>Edit Item <%= @item.id %></h2>
<form action="/items/<%= @item.id %>" method="POST">
<input type="hidden" name="_method" value="PATCH" />
<input type="text" size="75" name="title" placeholder="Enter new value for item <%= @item.id %>" value="<%= @item.title %>" />
<button>Update Item</button>
</form>
- Then create the update route and have it redirect to
'/items'
. Make sure it works. See the notes in the code below.
# update route
patch '/:id' do
# like i said -- lots of ways to do this.
# http://api.rubyonrails.org/classes/ActiveRecord/FinderMethods.html
# http://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-where
@items = Item.where(id: params[:id])
# note: .where method gives us an array (Why?). So we must index.
# Might there have been a more appropriate query method to use
# instead of .where ?
@item = @items[0]
@item.title = params[:title]
@item.save
redirect '/items'
end
π΄ Commit: "Update functionality. Full CRUD achieved."
Do you see why people say Ruby and Sinatra (or rails) and a good ORM let you build application prototypes very quickly????????
We're eventually going to track if the user is logged in with sessions. But first, let's do a sessions exercise -- create a messaging functionality.
Enable sessions:
# after bundler stuff but before everything else:
enable :sessions # yep, that's it
Set messages in routes:
# after .save, before redirect:
session[:message] = "You added item \##{@item.id}."
# after .save, before redirect:
session[:message] = "You updated item \##{@item.id}"
# after .delete, before redirect:
session[:message] = "You deleted item \##{@item.id}"
Create a style for the messages.
p.msg {
background: lightgreen;
color: brown;
border: 1px solid black;
}
Display message in layout.erb
. Remember to clear it from the session once it's been displayed.
<% if session[:message] %>
<p class="msg">
<%= session[:message] %>
</p>
<% session[:message] = nil
end %>
Pretty sweet, right?
Reminder: Anytime you're trying to figure out anything with sessions, you can
pp session
to see what's in there.
π΄ Commit: "Enabled sessions and used it to create messaging"
For parts 17 through 22, we will build a login and register functionality. Less guidance is provided. Based on what you've already done, think about what is required to build this and see if you can do it. Again, you have everything you need, so basically no googling should be required. Maybe a quick look back at class notes? Just keep asking yourself "what am I trying to do right now?" and "where have I done something like this before that worked?". Don't forget to p
or puts
or print
or pp
things to make sure they're what you think they are, and don't forget that you can see any SQL that ActiveRecord wrote for you in your terminal.
Remember: we already have users in our DB schema. Otherwise, we'd need to add that now.
π΄ Commit: "Added user model"
Don't worry about making the login actually work yet, just make sure everything is set up first, as we've done above with items. Routes exist, and just send back JSON. NOTE: ALL CONTROLLERS (except ApplicationController) IN A SINATRA APP INHERIT FROM APPLICATION CONTROLLER.
<h2>Sign up:</h2>
<form action="/user/register" method="POST">
<input type="text" name="username" placeholder="desired username" /><br />
<input type="password" name="password" placeholder="password"><br />
<button>Register</button>
</form>
<h2>Log in below.</h2>
<h3>Need an account? Sign up <a href="/user/register">here</a></h3>
<form action="/user/login" method="POST">
<input type="text" name="username" placeholder="username" /><br />
<input type="password" name="password" placeholder="password"><br />
<button>Login</button>
</form>
class UserController < ApplicationController
get '/' do
redirect '/user/login'
end
get '/login' do
erb :login
end
get '/register' do
erb :register
end
post '/login' do
params.to_json
end
post '/register' do
params.to_json
end
end
require './controllers/UserController'
and
map('/user') {
run UserController
}
π΄ Commit: "User login/register templates, User controller with dummy routes (that just send back JSON)"
Remember to set session variables appropriately--is the user logged in? Once they've successfully logged in, tell them "logged in as...." using the messaging functionality you just built.
Hint: try .find_by
Ok we said don't google, but if you want to read about ActiveRecord .find_by method real quick, that's ok. Don't spend 45 minutes reading stackoverflow posts by confused people. Just read about the method in the the ActiveRecord Query methods docs for like 1 minute. But you can probably just use .find_by without even doing that.
post '/login' do
@user = User.find_by(username: params[:username])
if @user && @user.password == params[:password]
session[:username] = @user.username
session[:logged_in] = true
session[:message] = "Logged in as #{@user.username}"
redirect '/items'
else
session[:message] = "Invalid username or password."
redirect '/user/login'
end
end
π΄ Commit: "Login works"
Remember session stuff.
Tip: Don't be rude to the user by forcing them to log in again after registering. Registering should automatically log them in.
post '/register' do
@user = User.new
@user.username = params[:username]
@user.password = params[:password]
@user.save
session[:logged_in] = true
session[:username] = @user.username
session[:message] = "Thank you for registering (as #{@user.username}). Enjoy the site!"
redirect '/items'
end
π΄ Commit: "Register works"
When the user is logged in, it should say, at the top of the page "Logged in as (username)" and show a logout link.
If the user is not logged in, only a Log In link should be displayed there.
nav {
display: flex;
justify-content: space-between;
}
<nav>
<div>
<p>Nav:</p>
<a href="/items">Item list</a> β’
<a href="/items/add">Add Items</a>
</div>
<div>
<% if !session[:logged_in] %>
<a href="/user/login">Login</a>
<% else %>
<p>Logged in as <%= session[:username] %></p>
<a href="/user/logout">Logout</a>
<% end %>
</div>
</nav>
π΄ Commit: "Logged in as.... in nav"
get '/logout' do
session[:username] = nil
session[:logged_in] = false
redirect '/user/login'
end
π΄ Commit: "Logout works"
In Express we could have written router-level middleware like this at the top of our controller:
app.use((req, res, next) => {
if(!session.loggedIn) {
req.session.message = "You must be logged in to do that."
res.redirect('/user/login');
} else {
next();
}
})
Writing actual Middleware for Rack-based apps is a little more complex, so we will create a filter with before
to achieve similar functionality.
# this is called a filter and will be run before all requests in this route
before do
if !session[:logged_in]
session[:message] = "You must be logged in to do that"
redirect '/user/login'
end
end
π΄ Commit: "You must be logged in to do that."
This is a critical step towards having multiple users!
- In your login and register routes, set
session[:user_id]
. - Remember to set it to back to
nil
in the logout route. - Update your item create route to use
session[:user_id]
for inserts instead of the1
we hard-coded in during step 10.
In the login route:
session[:user_id] = @user.id
In the register route:
session[:user_id] = @user.id
In the logout route:
session[:user_id] = nil
In the item create route:
@item.user_id = session[:user_id]
π΄ Commit: "Stores user_id in session. Updated item create route to use it."
For this step we will need to modify our database structure to store password hashes (ActiveRecord calls them password_digest
s) instead of plain-text passwords.
- Stop your server.
- In your migrations file, in the
CREATE TABLE users
statement, changepassword
to be:password_digest
. It MUST beVARCHAR(60)
--don't use a different number. The rest of the file is fine as is. - Open
psql
. Drop the entire database. - Copy
db/migrations.sql
and paste it into thepsql
console. - Do whatever investigating you need to do in the psql console to convince yourself everything went as you hoped.
Now, here's a little more ActiveRecord fairy dust. Here's how you set up the user model to bcrypt...just add one line:
class User < ActiveRecord::Base
has_secure_password # ridiculous
end
Now, if you're trigger happy, and you already tried it, you'll see that we're not quite done yet. You must update your login route where you check the password:
First, grab the password in the very first line of the route:
post '/login' do
@pw = params[:password]
...
Then, a few lines down, change your if statement after User.find
or User.find_by
:
# this is what you probably had before adding crypt. delete it
# if @user && @user.password == params[:password]
# ... and add this instead
if @user && @user.authenticate(@pw)
If everything went as planned, then you just implemented bcrypt authentication! Create a username.... actually, create a few usernames, and check in psql
(SELECT * FROM USERS;
) and you should see that the passwords are being encrypted.
π΄ Commit: "Added bcrypt"
Ok here's some sweet ActiveRecord magic stuff.
Set up the relations. A user "has many" items, and each item "belongs to" one user. These are "relational data" phrases. They are not arbitrary.
So.....
class Item < ActiveRecord::Base
belongs_to :user # add this
end
and...
class User < ActiveRecord::Base
has_secure_password
has_many :items # add this
end
Then in your item index route you can just do this:
# @items = Item.all # delete this and replace with:
@user = User.find session[:user_id]
# How cool is this
@items = @user.items
Now if you log in with different users and add items, you should only see the items for whoever you're logged in as.
Once again, head on over to your console and check out what SQL this is writing for you. Pretty sweet!
Did you notice that we have not done much to prevent hijacking -- ie, someone could update an item that's not theirs. How would that work and how could we stop it? Where are holes in our "security"?
π΄ Commit: "Added relations and updated item index to use it"
Cool so, now you can pretty do everything with Sinatra that you were doing with Express.
-
Add jQuery to the
layout.erb
. -
Create additional routes (leave the old ones) that do the same CRUD operations using ActiveRecord, but that only send back JSON.
-
In the JSON responses, include a message in the JSON saying if it was successful or something like that. Make that part identical for all the routes. In general make the JSON responses as consistently formatted as possible.
-
Once the user logs in, render (or provide a link to) a new view
:item_index_ajax
that is similar toitem_index
but without the<forms>
. Do NOT print the items with erb. Instead, add client-side JS AT THE BOTTOM ofitem_index_ajax
, and in your client-side JS, have an ajax 'GET' call that runs when the page loads. If it gets a successful response, that AJAX call's will include (or call a function that includes) logic to add the items to the page using jQuery DOM manipulation. -
Each item will still have a delete
<button>
. Just no forms. There won't be any<form>
s at all. -
For updating, there will be an "Edit" link for each item that will show a input (NO
<FORM>
, just<input>
) with the item data already populated, and an "update button." -
There will also be an 'Add Item' input and an 'Add Item' button that are always on the page.
-
Make the add, delete, and update buttons send AJAX requests (using jQuery's
$.ajax
) to your new routes, and when they get JSON back, they will update the html on the page accordingly with DOM manipulation. There should be no page refreshing for any/item
routes. -
Rember to clear the Add Item input field when you do a successful add, and hide the update form once you've successfully updated.