- Map the flow of data through a web application
- Use redirect
- Use Active Record callbacks
- Implement a user authorization scheme to limit unauthorized access to specific pages in a web application
- Write tests for complex web applications
- Deploy your application to Heroku
We're going to build a simple link shortener, a la bitly.
You'll have one model Url
, which is a list of URLs that people have entered.
Start with the empty Sinatra skeleton in source
.
We have one resource: Urls
. For our controllers, we have a URL that lists
all our Url
objects and another URL that, when POSTed to, creates a Url
object.
We'll also need a URL that redirects us to the full (unshortened) URL. If you've never used bitly, use it now to get a feel for how it works.
The controller methods should look like this:
get '/' do
# let user create new short URL, display a list of shortened URLs
end
post '/urls' do
# create a new Url
end
# e.g., /q6bda
get '/:short_url' do
# redirect to appropriate "long" URL
end
Use a before_save
callback in the Url
model to generate the short URL.
Add a click_count
field to your urls
table, which keeps track of how many
times someone has visited the shortened URL. Add code to the appropriate place
in your controller code so that any time someone hits a short URL the counter
for the appropriate Url
is incremented by 1.
Now its time for things to get complicated. Let's implement a login and logout feature for our URL shortener!
The core flow of the app should remain the same, however a person might choose
to log in or create an account.In the event that they're logged in when they shorten
a URL, this Url
should now be associated with their user account. In other words,
a Url
belongs to a User
and a User
has many Urls
.
Note: Don't worry about implement the above user-centric logic yet. First get all the URL shortening and user authentication code working. Make sure people can log in and can shorten URLs regardless of whether they're logged in or not.
There's going to be a lot going in your controller! Try to keep the code as readable and organized as possible. A file structure like this might make sense:
app/controllers/urls.rb
, which contains the routes related to listing, creating, and redirectingUrl
objectsapp/controllers/sessions.rb
, which contains the routes related to logging in and logging outapp/controllers/users.rb
, which contains the routes related to creating, displaying, and editing users
Now that you've got basic user authentication and URL shortening working, its time to stitch these features together. Depending on whether a user is signed in or not, your site will change dynamically.
People should be able to create short URLs regardless of whether they're logged
in or not. That is, the user_id
field on the urls
table could possible be
NULL
.
However, if a user is logged in, when we create a URL it should set the
user_id
to whatever the user_id
of the currently logged-in user is. This
information should not be a part of the form that a user submits — it
would be trivial for someone to change the content of the form and submit as
any user. The information should, instead, be stored in the users' session.
Users should now be able to view their URLs on their profile page, which should look like this:
get '/users/:id' do
end
This should display all the links that a particular user has created. If I'm viewing my own profile page, show the number of clicks next to each link so I can see how awesome my link-sharing skills are.
Since you'll be checking the session in every route, your routes are probably pretty repetative. But theres a better way! You can create helper methods to DRY up your controller and view code. You could use something like this:
helpers do
# This will return the current user, if they exist
# Replace with code that works with your application
def current_user
if session[:user_id]
@current_user ||= User.find_by_id(session[:user_id])
end
end
# Returns true if current_user exists, false otherwise
def logged_in?
!current_user.nil?
end
end
Your controller can now call current_user
to get the currently authenticated
user, if they exist. This means we don't have to rely on user-submitted data
to determine what user created a short URL.
Add a validation to your Url
model so that only Urls
with valid URLs get
saved to the database. Read up on ActiveRecord validations
What constitutes a "valid URL" is up to you. It's a sliding scale, from validations that would permit lots of invalid URLs to validations that might reject lots of valid URLs. When you get into it you'll see that expressing the fact "x is a valid URL" in Ruby Land or SQL Land is never perfect.
For example, the valid URL could range across:
A valid URL is...
- Any non-empty string
- Any non-empty string that starts with "http://" or "https://"
- Any string that the Ruby URI module says is valid
- Any URL-looking thing which responds to a HTTP request, i.e., we actually check to see if the URL is accessible via HTTP
Some of these are easily expressible in SQL Land. Some of these are hard to express in SQL Land, but ActiveRecord comes with pre-built validation helpers that make it easy to express in Ruby Land. Others require custom validations that express logic unique to our application domain.
The rule of thumb is that where we can, we want to always express constraints in Ruby Land and also express them in SQL Land where feasible.
When you try to save (create or update) an ActiveRecord object that has invalid
data, ActiveRecord will fail. Some methods like create!
and save!
throw an
exception. Others like create
(without the !
bang) return the resulting
object whether the object was saved successfully to the database or not, while
save
will return false
if perform_validation is true and any validations
fail. See create and save for more information.
You can always call valid? or invalid? on an ActiveRecord object to see whether its data is valid or invalid.
Use this and the errors method to display a helpful error message if a user enters an invalid URL, giving them the opportunity to correct their error.
We often want to put constraints on what sort of data can go into our database. This way we can guarantee that all data in the database conforms to certain standards, e.g., there are no users missing an email address. Guarantees of this kind — ensuring that the data in our database is never confusing or contradictory or partially changed or otherwise invalid — are called consistency.
If we think of this as a fact from Fact Land, these constraints look like:
- A user must have a first_name
- A user must have an email
- Two user's can't have the same email address, or equivalently, each user's email must be unique
- A Craigslist post's URL must be a valid URL, for some reasonable definition of valid
These facts can be recorded in both SQL Land and in Ruby Land, like this:
Fact Land | SQL Land | Ruby Land |
---|---|---|
A user must have an email address | NOT NULL constraint on email field |
validates :email, :presence => true |
A user must have a first name | NOT NULL constraint on first_name field |
validates :first_name, :presence => true |
A user's email address must be unique | UNIQUE INDEX on email field |
validates :email, :uniqueness => true |
You've been testing your models and controllers as you go, right? ...right? Well if you haven't, now is a good time to start. Make sure that all your validations and associations are tested, as well as all the transforms and side effects of your controller actions (a.k.a. routes). If a route updates a database, renders a page, or handles an edge case, you should write a test proving that your code works.
Many of your routes are now dependent on sessions, which will cause your tests to break. But that's okay! You can simply fake a user's session using rack-test, as the 'get' and 'post' method take an optional third argument which corresponds to the rack environment hash, where information about sessions is stored. It would look something like this:
it "should create a new post" do
fake_params = { title: "some title", content: "some content" }
fake_session = { 'rack.session' => { user_id: 20 } }
expect{
post '/some_route', fake_params, fake_session
}.to change{ Post.count }.by(1)
end
You're feature complete with 100% test coverage. It's time to push this thing into production! Get the app up on Heroku, and call it a night.