Dancer2...Tutorial(3)
π Dancer2::Manual::Tutorial(3) β the girlies' guide to getting you dancing π
User Contributed Perl Documentation Β· Dancer2...Tutorial(3)
okay bestie, grab a matcha because we are about to build a whole blog together, and I'm gonna walk you through every single step like we're sitting on my bedroom floor with a shared laptop. no gatekeeping here. π
β¨ NAME β¨
Dancer2::Manual::Tutorial β A step-by-step guide to get you dancing π©°
πΈ VERSION πΈ
version 2.2.1
π¦ Dancer2 Tutorial π¦
So here's the vibe: by the end of this tutorial you're gonna have a fully working blog application. Like, an actual one. Built from simple examples and lil demonstrations of the techniques real Dancer2 apps use. No cap.
If you're the kind of person who likes to peek at the finished product first (valid, honestly), you can clone the git repo with all the code used in this tutorial:
git clone https://github.com/PerlDancer/DLBlog.git
π Introduction β the Danceyland Blog π
So Danceyland (yes, that's a theme park, stay with me) wants to keep everybody posted on what's happening at the park, all the time, forever. And they've decided the best way to do that is publishing a blog. Your mission, should you choose to accept it: build them a blog engine using Dancer2 and some other Perl modules. Main character energy fr fr. π
β What You'll Learn β
By the time you finish this tutorial, you'll know how to:
- Scaffold a brand new Dancer2 app π£
- Design and construct routes for an application π£οΈ
- Work with sessions, templates, and databases πΎ
- Extend your Dancer2 applications with plugins π
- Add authentication to Dancer2 applications π
- Perform a basic app deployment π
π Prerequisites π
- Linux, BSD, or Windows with WSL and a Linux distribution installed
(Strawberry Perl on Windows may work, but is not guaranteed β so like, don't come for me if it doesn't π)
- SQLite 3, installed via your operating system's package manager
π§ Setting Up Your Environment π§
π Installing Dancer2
cpan Dancer2
or
cpanm Dancer2
π Creating Your First App
dancer2 gen -a DLBlog
Output:
+ DLBlog
+ DLBlog/.dancer
+ DLBlog/config.yml
+ DLBlog/cpanfile
+ DLBlog/Makefile.PL
+ DLBlog/MANIFEST.SKIP
+ DLBlog/t
+ DLBlog/t/002_index_route.t
+ DLBlog/t/001_base.t
+ DLBlog/environments
+ DLBlog/environments/production.yml
+ DLBlog/environments/development.yml
+ DLBlog/bin
+ DLBlog/bin/app.psgi
+ DLBlog/views
+ DLBlog/views/index.tt
+ DLBlog/public
+ DLBlog/public/dispatch.cgi
+ DLBlog/public/404.html
+ DLBlog/public/favicon.ico
+ DLBlog/public/500.html
+ DLBlog/public/dispatch.fcgi
+ DLBlog/lib
+ DLBlog/lib/DLBlog.pm
+ DLBlog/views/layouts
+ DLBlog/views/layouts/main.tt
+ DLBlog/public/images
+ DLBlog/public/images/perldancer.jpg
+ DLBlog/public/images/perldancer-bg.jpg
+ DLBlog/public/javascripts
+ DLBlog/public/javascripts/jquery.js
+ DLBlog/public/css
+ DLBlog/public/css/style.css
+ DLBlog/public/css/error.css
Your new application is ready! To run it:
cd DLBlog
plackup bin/app.psgi
To access your application, point your browser to http://localhost:5000
If you need community assistance, the following resources are available:
- Dancer website: https://perldancer.org
- Twitter: https://twitter.com/PerlDancer/
- GitHub: https://github.com/PerlDancer/Dancer2/
- Mailing list: https://lists.perldancer.org/mailman/listinfo/dancer-users
- IRC: irc.perl.org#dancer
Happy Dancing!
Make sure you actually change into your new project directory before you do anything else, ok? Don't be that girl working in the wrong folder:
cd DLBlog
π¦ Understanding Directory Structure π¦
Your app directory is now stacked with subdirectories and here's what each one is giving:
- bin β Contains the PSGI file that starts your application. Can be used for other utilities and scripts for your application.
- environments β Configuration information. One file per environment (such as
"development"and"production"). - lib β Your core application code. This is where you'll put your models, controllers, and other code.
- public β Contains static files such as images, CSS, and JavaScript. Also contains instance scripts for CGI and FastCGI.
- t β Tests for your application.
- views β Contains templates and layouts.
π₯ Using Plackup for Development π₯
plackup -r bin/app.psgi
This autorestarts your application whenever changes are made to your code. It's literally ideal during development, but bestie β do not use this in a production setting. That's a no. π«
π Configuring Our Application π
By default, new Dancer2 applications use Dancer2::Template::Tiny. And like, that works well for some types of apps, but it's just not well suited to applications like the Danceyland Blog. So instead, we're gonna configure this app to use Template::Toolkit, which is a total mainstay of Perl templating. Iconic.
To enable Template Toolkit, you'll need to edit your config.yml:
# template engine
# tiny: default basic template engine
# template_toolkit: TT
#template: "tiny"
template: "template_toolkit"
engines:
template:
template_toolkit:
# Note: start_tag and end_tag are regexes
start_tag: '<%'
end_tag: '%>'
YAML files, like your application config, support the same comment characters as Perl. Dancer2::Template::Tiny gets disabled by commenting that line out in your config file, and the default TT config is used by uncommenting the "template" and "engines" configuration. Easy.
So this is telling Dancer2 to:
- Load the Template Toolkit template engine
- Specify Template Toolkit as the template engine to use
- Passes configuration to the TT engine (
"start_tag, end_tag")
We're gonna be visiting this file regularly throughout the tutorial, so don't forget where it lives. π
config.yml is the right place to set configuration common to all environments (dev, production, etc.), such as the template engine to use. Later, we'll see how to use different configuration parameters in different environments.
π CRUD Routes π
CRUD is an acronym for Create, Read, Update, and Delete. These are the basic operations for any database-driven application β such as the Danceyland Blog. The fundamentals, the blueprint, the moment. π§
πΈ What Routes are Needed? πΈ
Blogs contain one or more entries, which we need to be able to Create, Read, Update, and Delete. So we'll need the following routes:
- Create a blog entry. This corresponds to the HTTP
"POST"method. It also requires a form to input the data, which is displayed using the HTTP"GET"method. - Read (i.e., display) a blog entry. This corresponds to the HTTP
"GET"method. - Update a blog entry. If we were building an API or single-page application, this would correspond to the HTTP
"PUT"or"PATCH"methods. In a traditional CRUD-based web application, this will use the"POST"method.
There is a UI component to this as well, which will use the HTTP "GET" method.
- Delete a blog entry. If we were building an API or single-page application, this would correspond to the HTTP
"DELETE"method. However, for simplicity, we'll use the HTTP"GET"method to prompt the user for confirmation, and a"POST"route to actually perform the deletion.
We'll also need a route to see the list of all blog entries. This will use the HTTP "GET" method.
β¨ Add an Entry (Create) β¨
This requires two routes: one to display the create form, and another to process the input and create the entry.
To display the entry form:
get '/create' => sub {
return "Show the entry form";
};
To process the input and create the blog entry:
post '/create' => sub {
return "Creates the new blog entry";
};
π Display an Entry (Read) π
This route is similar to the one needed to display the creation form:
get '/entry/:id' => sub {
my $id = route_parameters->get('id');
return "Showing entry ID $id";
};
The route declaration contains a parameter this time: the id of the blog entry we wish to view. The "route_parameters" keyword is used to fetch the ID passed to our application. π«Ά
π¦ Update an Entry (Update) π¦
As with adding an entry, this requires two routes: one to display the entry form, and another to process the changes:
get '/update/:id' => sub {
my $id = route_parameters->get('id');
return "Show the update form for entry id $id";
};
post '/update/:id' => sub {
my $id = route_parameters->get('id');
return "Updates the specified blog entry";
};
Again, the entry ID is provided to our Dancer2 application as a route parameter.
π Delete an Entry (Delete) π
This also needs two routes: one to confirm the user wants to delete the blog entry, and another to actually delete it:
get '/delete/:id' => sub {
my $id = route_parameters->get('id');
return "Show the delete confirmation for entry id $id";
};
post '/delete/:id' => sub {
my $id = route_parameters->get('id');
return "Deletes the specified blog entry";
};
β Displaying the Entry List β
Finally, we need a route to display the list of all blog entries:
get '/' => sub {
return "Show the list of blog entries";
};
π Putting it all Together π
So far, lib/DLBlog.pm should look like this:
package DLBlog;
use Dancer2;
get '/' => sub {
return "Show the list of blog entries";
};
get '/entry/:id' => sub {
my $id = route_parameters->get('id');
return "Showing entry ID $id";
};
get '/create' => sub {
return "Show the entry form";
};
post '/create' => sub {
return "Creates the new blog entry";
};
get '/update/:id' => sub {
my $id = route_parameters->get('id');
return "Show the update form for entry id $id";
};
post '/update/:id' => sub {
my $id = route_parameters->get('id');
return "Updates the specified blog entry";
};
get '/delete/:id' => sub {
my $id = route_parameters->get('id');
return "Show the delete confirmation for entry id $id";
};
post '/delete/:id' => sub {
my $id = route_parameters->get('id');
return "Deletes the specified blog entry";
};
true;
Run your application! πββοΈ
Let's see how it works. From the shell:
plackup -r bin/app.psgi
Then open your browser and test your GET routes (we have nothing to post yet!):
- <http://localhost:5000/>
- <http://localhost:5000/entry/1>
- <http://localhost:5000/create>
- <http://localhost:5000/update/1>
- <http://localhost:5000/delete/1>
πΈ Setting Up Views πΈ
π§ Layout π§
Let's replace views/layouts/main.tt with simple HTML5 boilerplate:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="<% settings.charset %>">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title><% title %></title>
<link rel="stylesheet" href="<% request.uri_base %>/css/style.css">
</head>
<body>
<% content %>
<div id="footer">
Powered by <a href="https://perldancer.org/">Dancer2</a> <% dancer_version %>
</div>
</body>
</html>
okay so this is our barebones little starter kit that we're gonna build the whole blog on top of π«Ά It handles character encoding properly, sets the application title, pulls in a stylesheet, and drops a footer down at the bottom showing which version of Dancer2 is running the whole show. iconic β¨
π Templates π
See that special <% content %> variable up there? That's literally where every template our routes render gets slotted in. So instead of returning plain strings from the routes we wrote, let's make actual templates holding the UI we need to mess with the blog, plus the business logic that decides when to show them. vibe check passed π
π The Index Route ("/")
Swap out the existing views/index.tt for this:
<div id="entries">
<% IF entries.size %>
<dl>
<% FOREACH entry IN entries %>
<dt><a href="/entry/<% entry.id %>"><% entry.title | html_entity %></a>
(created at <% entry.created_at | html_entity %>)</dt>
<dd><% entry.summary | html_entity %></dd>
<% END %>
</dl>
<% ELSE %>
<p>No entries found.</p>
<% END %>
</div>
This list is gonna get populated later, bestie β specifically when we read entries out of the database further down this tutorial. π¦
And what's the deal with all these html_entity things?? Okay so the | is Template Toolkit syntax for a filter, and html_entity is the filter we're using here. This particular one properly escapes characters like ampersands when it displays them as HTML. safety first, always π‘οΈ
To actually use this template in our app, we plug it into our route with the template keyword:
get '/' => sub {
my @entries; # We'll populate this later
template 'index', { entries => \@entries };
};
π¦ The View Entry Route "/entry/:id"
New template time β views/entry.tt β and we're loading it with everything we think we'll need to show off a blog entry:
<% IF entry %>
<div id="sidebar">
<h1>Posted at</h1>
<p><% entry.created_at | html_entity %></p>
<h1>Summary</h1>
<p><% entry.summary | html_entity %></p>
</div>
<div id="content">
<h2><% entry.title | html_entity %></h2>
<p><% entry.content | html_entity %></p>
<% ELSE %>
<p>Invalid entry.</p>
<% END %>
</div>
entry here is a hashref holding the contents of our blog post. If a valid entry ID doesn't show up, we tell the user what's up instead. Otherwise they get a two-column layout: metadata chilling in the sidebar, blog content taking the main stage. <sparkle>main character energy β¨</sparkle>
fair warning: this is gonna be ugly as written. We add styling at the end to make it look nicer, promise π
Now let's tweak our viewing route to use the template:
get '/entry/:id' => sub {
my $id = route_parameters->get('id');
my $entry; # Populated from the database later
template 'entry', { entry => $entry };
};
π The Create Entry Routes "/create"
Just a lil simple form to make a new blog entry. Make views/create.tt with this inside:
<div id="create">
<form method="post" action="<% request.uri_for('/create') %>">
<label for="title">Title</label>
<input type="text" name="title" id="title"><br>
<label for="summary">Summary</label>
<input type="text" name="summary" id="summary"><br>
<label for="content">Content</label>
<textarea name="content" id="content" cols="50" rows="10"></textarea><br>
<input type="submit">
</form>
</div>
Using uri_for when specifying the POST URL lets Dancer2 cook up a proper URL no matter where your app happens to be running from. portable queen π
Drop it into the create route:
get '/create' => sub {
template 'create';
};
There's no template when POSTing to /create. There are two possible outcomes when creating a blog entry:
- Creation was successful, so we redirect somewhere sensible
- Creation failed, so we redisplay the filled in form and show an error
We'll show how to do both of these in the implementation section later. For now:
post '/create' => sub {
my $new_id = 1;
redirect uri_for "/entry/$new_id"; # redirect does not need a return
};
π The Update Entry Routes ("/update/:id")
Showing the UI for updating a blog entry is basically the same thing we did in create above, except we need to display the existing values of an entry inside the form. Like⦠we're not gonna make the user re-enter everything when updating, are we?? Absolutely not, bestie. Since we have no values to show right now, we implement that latter part in the implementation section below.
For now, let's just return the existing create form:
get '/update/:id' => sub {
my $id = route_parameters->get('id');
template 'create';
};
We don't need a special template for showing the results of an update β we just need to show the resulting entry. And waitβ¦ don't we already have a route that does that? Why would we do that work again here? We shouldn't! reuse is the vibe π
Dancer2 will let you change the flow of your application using the forward or redirect keywords. But which one do we pick? π€
forwardis an internal redirect This lets you perform the functionality contained in another route, but it's done internally, which means the URL shown to the user in their browser's address bar looks like the one they requested. In the case of our update, the browser bar will showhttp://localhost:5000/update/1, but what they'd actually be seeing is the output fromhttp://localhost:5000/entry/1. This is not desirable, because it's confusing for a user to look at.redirectis an external redirect External redirects send the user to a different URL by sending a redirect response from Dancer2. Even though the user is executing a POST tohttp://localhost:5000/update/1, upon completion, their browser bar will showhttp://localhost:5000/entry/1. This is the better option, because when an update completes successfully, the URL will match what the user sees in their browser. slay β¨
To do this, let's use the redirect keyword:
post '/update/:id' => sub {
my $id = route_parameters->get('id');
redirect uri_for "/entry/$id"; # redirect does not need a return
};
We will show how to update an existing entry later in the tutorial.
πΈ The Delete Routes
It wouldn't be kind to the user to just delete an entry without giving them the option to change their mind β and that's exactly what the GET route for deletion is for. consent, but for data π
Let's give the user a simple confirmation form. Make views/delete.tt:
<div id="delete">
<form method="post" action="<% request.uri_for('/delete/' _ id) %>">
<p>Delete entry <% id %>. Are you sure?</p>
<p>
<input type="radio" id="yes" name="delete_it" value="yes">
<label for="yes">Yes</label>
</p>
<p>
<input type="radio" id="no" name="delete_it" value="no">
<label for="no">No</label>
</p>
<input type="submit">
</form>
</div>
Our GET route for delete needs to change:
get '/delete/:id' => sub {
my $id = route_parameters->get('id');
template 'delete', { id => $id };
};
Same energy as create and update: there's no need to make another template just to show the results of a delete. We'll simply send the user back to the list of blog entries:
post '/delete/:id' => sub {
my $id = route_parameters->get('id');
# Always default to not destroying data
my $delete_it = body_parameters->get('delete_it') // 0;
if( $delete_it ) {
# Do the deletion here
redirect uri_for "/";
} else {
# Display our entry again
redirect uri_for "/entry/$id";
}
};
Notice the addition of body_parameters? That's how we grab the value of the radio button the user picked. Super important for deciding whether the user actually wants to delete or not. π«Ά
β Reviewing our Application Code β
lib/DLBlog.pm should now look like this:
package DLBlog;
use Dancer2;
get '/' => sub {
my @entries; # We'll populate this later
template 'index', { entries => \@entries };
};
get '/entry/:id' => sub {
my $id = route_parameters->get('id');
my $entry; # Populated from the database later
template 'entry', { entry => $entry };
};
get '/create' => sub {
template 'create';
};
post '/create' => sub {
my $new_id = 1;
redirect uri_for "/entry/$new_id"; # redirect does not need a return
};
get '/update/:id' => sub {
my $id = route_parameters->get('id');
template 'create';
};
post '/update/:id' => sub {
my $id = route_parameters->get('id');
redirect uri_for "/entry/$id";
};
get '/delete/:id' => sub {
my $id = route_parameters->get('id');
template 'delete', { id => $id };
};
post '/delete/:id' => sub {
my $id = route_parameters->get('id');
# Always default to not destroying data
my $delete_it = body_parameters->get('delete_it') // 0;
if( $delete_it ) {
# Do the deletion here
redirect uri_for "/";
} else {
# Display our entry again
redirect uri_for "/entry/$id";
}
};
true;
look at us, we have a whole app π₯Ή
π Adding Utility to our Views π
Okay so all of this is well and good, but it's not the most functional for our users. A basic menu of available options would help users understand what actions can be performed in the Danceyland Blog. ngl, wayfinding is everything π
π§ Adding a Menu to our Layout
Okay so picture this: every single page of our app needs that same lil menu, and copying it into the list, create, update, and delete templates would be a crime honestly. So we're putting it in the layout once. Iconic behavior, zero duplication. π
Our menu is giving:
<div id="menu">
<a href="<% request.uri_for('/') %>">List All Entries</a> |
<a href="<% request.uri_for('/create') %>">Create New Entry</a>
</div>
Now bestie, we drop it at the top of our layout. The whole thing ends up looking like:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="<% settings.charset %>">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title><% title %></title>
<link rel="stylesheet" href="<% request.uri_base %>/css/style.css">
</head>
<body>
<div id="menu">
<a href="<% request.uri_for('/') %>">List All Entries</a> |
<a href="<% request.uri_for('/create') %>">Create New Entry</a>
</div>
<% content %>
<div id="footer">
Powered by <a href="https://perldancer.org/">Dancer2</a> <% dancer_version %>
</div>
</body>
</html>
Refresh your browser and boom β menu at the top of your page. Slay. β¨
π The Danceyland Database π
Okay so we need somewhere to persist our blog entries, and relational databases are literally built for this. We're using SQLite for this tutorial, but fr fr you can use any database supported by Dancer2::Plugin::Database and DBI. Your choice, your vibe. π¦
SQLite <https://sqlite.org> is a lightweight, single file database β it makes adding relational database functionality to a low-concurrency web application super easy. Lowkey perfect for us right now.
π Setting Up the Database π
At minimum we need a table to hold our blog entries. First, make a new directory for your database and SQL files:
$ mkdir db
Then create a new file, db/entries.sql, with this inside:
CREATE TABLE entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
summary TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Later on we'll add an additional table for users. One thing at a time, bestie.
Now let's actually create our blog database with that table. From your project directory:
$ sqlite3 db/dlblog.db < db/entries.sql
And that's it, we have a database. Periodt. π
π§ Using Dancer2::Plugin::DBIx::Class π§
Dancer2::Plugin::DBIx::Class is a plugin that integrates the DBIx::Class Object Relational Mapper (ORM) and Dancer2. It maps tables, rows, and columns into classes, objects, and methods in Perl. DBIx::Class (DBIC for short, because we're all about the abbreviations) makes working with databases in your Perl apps genuinely convenient and cuts down on a ton of manual SQL writing.
Real talk though: DBIC is also a large and complex beast, and it can take some time to become proficient with it. This tutorial merely scrapes the surface of what you can do with it; for more information, check out DBIx::Class::Manual::DocMap. π
You'll also need to install two other dependencies β DBD::SQLite (the SQLite database driver), DBIx::Class::Schema::Loader (for automatically generating database classes from tables), and DateTime::Format::SQLite (to interact with dates in DBIC objects).
Install them using cpan or cpanm:
cpanm DBD::SQLite Dancer2::Plugin::DBIx::Class \
DBIx::Class::Schema::Loader DateTime::Format::SQLite
Add these to your cpanfile:
requires "DBD::SQLite";
requires "Dancer2::Plugin::DBIx::Class";
requires "DBIx::Class::Schema::Loader";
requires "DateTime::Format::SQLite";
And then add it to the top of lib/DLBlog.pm right after use Dancer2;:
use Dancer2::Plugin::DBIx::Class;
Now we gotta add configuration telling our plugin where to find the SQLite database. For this project it's totally sufficient to put the database config in config.yml. In a production application you'd have different database credentials in your development and staging environments than in your production environment (we'd hope you would anyhow!). And that is exactly where environment config files come in handy. πΈ
By default, Dancer2 runs your application in the development environment. So we'll add plugin configuration accordingly. Add this to your environments/development.yml file:
plugins:
DBIx::Class:
default:
dsn: "dbi:SQLite:dbname=db/dlblog.db"
schema_class: "DLBlog::Schema"
dbi_params:
RaiseError: 1
AutoCommit: 1
Not me pointing out that we only provided DBIx::Class for the plugin name β Dancer2 automatically infers the Dancer2::Plugin:: prefix. Magic. β¨
Since SQLite databases are just a local file, we don't need to provide login credentials for the database. The two settings in the dbi_params section tell DBIx::Class to raise an error automatically to our code (should one occur), and to automatically manage transactions for us (so we don't have to). Obsessed with the convenience honestly. π«Ά
π Generating Schema Classes π
DBIx::Class relies on class definitions to map database tables to Perl constructs. Thankfully, DBIx::Class::Schema::Loader can do much of this work for us. Thank you loader, you're the main character. π
To generate the schema object, and a class that represents the entries table, run the following from your shell:
dbicdump -o dump_directory=./lib \
-o components='["InflateColumn::DateTime"]' \
DLBlog::Schema dbi:SQLite:db/dlblog.db '{ quote_char => "\"" }'
This creates two new files in your application:
lib/DLBlog/Schema.pmThis is a class that represents all database schema.lib/DLBlog/Schema/Result/Entry.pmThis is a class representing a single row in theentriestable.
Two files, zero manual class-writing. We ate. π
π Implementing the Danceyland Blog π
okay bestie, first things first: we have to create an entry and save it to the database. literally every other route depends on us having at least one entry (in some form or another). no entry, no blog, no slay. periodt.
π Performing Queries π
Dancer2::Plugin::DBIx::Class is the moment β it lets us run SQL queries against a database without, like, hand-writing SQL every single time. it hands us methods to interact with our data: find, search, create, and update. these are the icons, honestly. they do all the work of writing and executing the SQL in the background, so our code stays way easier to maintain. ate. π
so let's use a convenience method to create a new blog entry. here's the form we made for entering a blog post:
<div id="create">
<form method="post" action="<% request.uri_for('/create') %>">
<label for="title">Title</label>
<input type="text" name="title" id="title"><br>
<label for="summary">Summary</label>
<input type="text" name="summary" id="summary"><br>
<label for="content">Content</label>
<textarea name="content" id="content" cols="50" rows="10"></textarea><br>
<button type="submit">Save Entry</button>
</form>
</div>
and we can take the values submitted through this form and turn them into an actual row in the database, like this:
post '/create' => sub {
my $params = body_parameters();
my $entry = do {
try {
resultset('Entry')->create( $params->as_hashref );
}
catch( $e ) {
error "Database error: $e";
var error_message => 'A database error occurred; your entry could not be created',
forward '/create', {}, { method => 'GET' };
}
};
redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
};
form fields get sent to Dancer2 as body parameters, so we need the body_parameters keyword to grab them:
my $params = body_parameters();
that gives us back all the body parameters as one single hashref, where every form field name is a key and the value is whatever the user typed into that field. cute, right? π
real talk though: in a production environment you'd want to sanitize this data before you even think about touching a database operation. sanitizing means making sure the data only contains the values you'd actually expect to receive. if it's not what you expected, you can either strip the extra cruft out (that's the sanitizing part) or ask the user to fix their entry. πΈ
database operations can fail β that's just life β so we should make an attempt to trap any errors. try/catch is honestly made for this kind of error checking. newer versions of Perl have a built-in try keyword, but older versions don't. to protect against that, let's install Feature::Compat::Try β it uses the built-in try if your Perl has it, and otherwise provides a backported implementation. to install it, run cpanm:
cpanm Feature::Compat::Try
and add it to your cpanfile:
requires "Feature::Compat::Try";
then make sure you include it at the top of your application:
use Feature::Compat::Try;
the code inside the try block gets executed, and if it fails, it catches the error and runs the code in that block.
try {
resultset('Entry')->create( $params->as_hashref );
}
this uses the quick_insert method of our Database plugin, and passes the values from the form through to create a row in the entries table.
if a database error happens, we have to handle it:
catch( $e ) {
error "Database error: $e";
var error_message => 'A database error occurred; your entry could not be created',
forward '/create', {}, { method => 'GET' };
}
the first line makes an error log message containing the actual database error β this is gonna be gold when you're debugging your app or troubleshooting something a user hit. then we stash a message in a variable so it can be displayed on the create page once that page gets redisplayed. for the sake of brevity, we populate the message with a really basic error message. in a production application you'd want to hand the user something more descriptive to help them resolve their own problem, if possible. π«Ά
and why not just pass the database error straight to the user? because that hands a potential attacker free information about your database and your application. do not do that. π
finally, we send the user back to the entry form:
forward uri_for '/create', {}, { method => 'GET' };
by default, forward invokes a route using the same HTTP verb as the route it's executing from. you can change the verb by passing a third hashref containing the method key. the second (empty) hashref holds an optional list of parameters to pass along to the forwarded route.
if the insert succeeds, create returns an object representing the newly created database row, and we assign it to the $entry variable. we can run extra database operations against that row by calling methods on $entry. and as a little convenience for the user, we should take them somewhere they can actually see their brand new entry:
debug 'Created entry ' . $entry->id . ' for "' . $entry->title . '"';
redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
the first line logs a message showing the post was successfully created, then the last line redirects the user to the entry display page. main character behaviour honestly. π
π Redisplaying the Create Form π
when there's an error, it's good practice to redisplay the original form with the previous values still filled in. we could add code in our POST route to redisplay the form... or we could just reuse the code we already wrote to display the form. yeah, we're going with the second one. recycling is iconic. β»οΈ
Dancer2's var keyword lets you create variables you can use elsewhere in your application, including in your templates. we'll use these to keep track of what the user already entered, and to show the user a message if any required parameters are missing.
to save any entered parameters into vars, add this line right after the call to body_parameters:
var $_ => $params->{ $_ } foreach qw< title summary content >;
now let's check whether any of those were left out when the user submitted the create form:
my @missing = grep { $params->{$_} eq '' } qw< title summary content >;
if( @missing ) {
var missing => join ",", @missing;
warning "Missing parameters: " . var 'missing';
forward '/create', {}, { method => 'GET' };
}
if any of title, summary, or content are missing, we build up a message containing the list of missing parameters. then we set a variable, missing, with the message to display. and finally we internally redirect (via the forward keyword) back to the GET route that displays our create form. no cap, that's the whole trick. β¨
your new post '/create' route should now look like this:
post '/create' => sub {
my $params = body_parameters();
var $_ => $params->{ $_ } foreach qw< title summary content >;
my @missing = grep { $params->{$_} eq '' } qw< title summary content >;
if( @missing ) {
var missing => join ",", @missing;
warning "Missing parameters: " . var 'missing';
forward '/create', {}, { method => 'GET' };
}
my $entry = do {
try {
resultset('Entry')->create( $params->as_hashref );
}
catch( $e ) {
error "Database error: $e";
var error_message => 'A database error occurred; your entry could not be created',
forward '/create', {}, { method => 'GET' };
}
};
debug 'Created entry ' . $entry->id . ' for "' . $entry->title . '"';
redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
};
these changes to our post '/create' route make for some pretty succinct code that's easy to follow β but it might not be the best for a production application. better error handling could be added, there's no retry logic for failed database connections, data validation is lacking, etc. all of those features can be added to the blog later as additional exercises for the developer. homework era. π
π Updating the create form to show previous values π
we need to adjust the create form so it shows the values we stashed as variables with the var keyword:
<div id="create">
<form method="post" action="<% request.uri_for('/create') %>">
<label for="title">Title</label>
<input type="text" name="title" id="title" value="<% vars.title %>"><br>
<label for="summary">Summary</label>
<input type="text" name="summary" id="summary" value="<% vars.summary %>"><br>
<label for="content">Content</label>
<textarea name="content" id="content" cols="50" rows="10"><% vars.content %></textarea><br>
<button type="submit">Save Entry</button>
</form>
</div>
variables stashed in your Dancer2 application are available through the vars hashref, like <% vars.title %>. so when /create displays this form, any stashed variable values get filled right back into their appropriate form element. it's giving memory. π§ β¨
π¬ Displaying messages π¬
in our application we created a message to display a list of any missing required fields, and we also created an error message for when our database operation fails. now we need to create a place in the layout to actually display them.
below our menu, but above our content, add the following:
<% IF vars.missing %>
<div id="missing">
<b>Missing parameters: <% vars.missing | html_entity %></b>
</div>
<% END %>
<% IF error_message %>
<div id="error">
<b>Error: <% vars.error_message | html_entity %></b>
</div>
<% END %>
this creates two message divs: one that displays missing values, and another that displays errors. keeping them separate means we can style each one appropriately later. aesthetic. π
see those IF/END blocks? that content is optional β meaning if there are no missing fields and no error messages, this markup won't get added to the rendered HTML page at all. so clean. π¦
the layout should now look like:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="<% settings.charset %>">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title><% title %></title>
<link rel="stylesheet" href="<% request.uri_base %>/css/style.css">
</head>
<body>
<div id="menu">
<a href="<% request.uri_for('/') %>">List All Entries</a> |
<a href="<% request.uri_for('/create') %>">Create New Entry</a>
</div>
<% IF vars.missing %>
<div id="missing">
<b>Missing parameters: <% vars.missing | html_entity %></b>
</div>
<% END %>
<% IF error_message %>
<div id="error">
<b>Error: <% vars.error_message | html_entity %></b>
</div>
<% END %>
<% content %>
<div id="footer">
Powered by <a href="https://perldancer.org/">Dancer2</a> <% dancer_version %>
</div>
</body>
</html>
π Displaying Blog Data π
okay so showing off one single blog entry? genuinely the easiest little thing, bestie. the quick_select method of Dancer2::Plugin::Database hands you back a database row as a hashref, and you just... pass that bad boy straight to a template as a parameter. that's it. that's the whole vibe:
get '/entry/:id[Int]' => sub {
my $id = route_parameters->get('id');
my $entry = resultset( 'Entry' )->find( $id );
template 'entry', { entry => $entry };
};
you might clock that the route declaration changed π and yeah, because Dancer2 will let you decorate a route parameter with a Type::Tiny datatype. so if the parameter someone throws at you doesn't match the type Dancer2 expects, you get an HTTP 404 status back. no negotiating. and the glow-up? any call to route_parameters is then guaranteed to hand you a value of the desired type. no more vibe-checking your inputs yourself, she's got you π«Ά
we're using the find() method of DBIx::Class::ResultSet to grab a single row and turn it into an object, which we'll call $entry like it's her name. and should find not succeed? our template will say so, loudly and cutely:
<% IF entry %>
<div id="sidebar">
<h1>Posted at</h1>
<p><% entry.created_at | html_entity %></p>
<h1>Summary</h1>
<p><% entry.summary | html_entity %></p>
</div>
<div id="content">
<h2><% entry.title | html_entity %></h2>
<p><% entry.content | html_entity %></p>
<% ELSE %>
<p>Invalid entry.</p>
<% END %>
</div>
by passing the resultset object directly into the template, you can call methods right on that object to display different columns of info, like title and content. and if there's no valid entry? the ELSE section of the template shows up instead of the blog post content. iconic behaviour honestly β¨
π Updating a blog entry π
to update a blog entry we need a form holding the values that were already entered for a given post. and like... didn't we literally already do that when we were redisplaying the create form because values were missing? yes. yes we did. so what if we could reuse that exact form for editing an existing entry? with a little bit of work, we absolutely can. obsessed.
rename create.tt to be create_update.tt, then swap the contents for this:
<div id="create_update">
<form method="post" action="<% post_to %>">
<label for="title">title</label>
<input type="text" name="title" id="title" value="<% vars.title %>"><br>
<label for="summary">summary</label>
<input type="text" name="summary" id="summary" value="<% vars.summary %>"><br>
<label for="content">content</label>
<textarea name="content" id="content" cols="50" rows="10"><% vars.content %></textarea><br>
<button type="submit">Save Entry</button>
</form>
</div>
the following minor changes have been made, and yes they're small but they matter:
- the div id was changed to reflect the form's new purpose
- the form action was changed to the template variable
post_to
the form action is gonna be different depending on whether we're creating a new blog entry or updating an existing one. that's the whole point of post_to bestie π
so now we need a route to display the form in a way that's suitable for updating a blog entry:
get '/update/:id[Int]' => sub {
my $id = route_parameters->get('id');
my $entry = resultset( 'Entry' )->find( $id );
var $_ => $entry->$_ foreach qw< title summary content >;
template 'create_update', { post_to => uri_for "/update/$id" };
};
same as our display-a-blog-entry route, we include the type of parameter (Int) we expect to receive. and Dancer2 will throw a 404 not found error if that parameter is anything other than an integer. periodt. we also try to fetch the row from the entries table identified with the id passed to the app.
once an entry has been retrieved, we populate the exact same list of variables that we did to redisplay the form when required values were missing earlier. then finally, we build the correct post_to URL for updating a blog entry and pass it along to the template. chef's kiss π
and just like with create, we need a POST route to actually process the blog update:
post '/update/:id[Int]' => sub {
my $id = route_parameters->get('id');
my $entry = resultset( 'Entry' )->find( $id );
if( !$entry ) {
status 'not_found';
return "Attempt to update non-existent entry $id";
}
my $params = body_parameters();
var $_ => $params->{ $_ } foreach qw< title summary content >;
my @missing = grep { $params->{$_} eq '' } qw< title summary content >;
if( @missing ) {
var missing => join ",", @missing;
warning "Missing parameters: " . var 'missing';
forward "/update/$id", {}, { method => 'GET' };
}
try {
$entry->update( $params->as_hashref );
}
catch( $e ) {
error "Database error: $e";
var error_message => 'A database error occurred; your entry could not be updated',
forward "/update/$id", {}, { method => 'GET' };
}
redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
};
there's an extra check in here that wasn't needed for create: a check to make sure the blog post we're updating actually exists. if it doesn't, the correct response is to fire off a 404 not found at the user. no cap:
if( !$entry ) {
status 'not_found';
return "Attempt to update non-existent entry $id";
}
the status keyword sets the proper response code in the response header, and the message in the return sends extra info back to the user. so they know exactly what went wrong π
once the proper blog entry has been loaded, the exact same logic needed to create an entry applies to updating one. literally the same. we love reuse.
Updating the create form's post_to
just call uri_for in our route, then hand the resulting value over to create_update.tt:
get '/create' => sub {
# Vars are passed to templates automatically
template 'create_update', { post_to => uri_for '/create' };
};
π¦ Deleting a Blog Entry π¦
a simple GET route is needed to show info about the route to be deleted:
get '/delete/:id[Int]' => sub {
my $id = route_parameters->get('id');
my $entry = resultset( 'Entry' )->find( $id );
template 'delete', { entry => $entry };
};
we require a single route parameter: an integer that represents the ID of the blog post we wish to delete. then we try to load that entry from the database with the find() method of DBIx::Class::ResultSet, and dump it into our earlier delete template. easy.
speaking of which β let's modify our template to use methods on our resultset object:
<% IF entry %>
<div id="delete">
<form method="post" action="<% request.uri_for('/delete/' _ entry.id) %>">
<p>Delete entry <% entry.id %>. Are you sure?</p>
<div>
<input type="radio" id="yes" name="delete_it" value="yes">
<label for="yes">Yes</label>
</div>
<div class="form-check">
<input type="radio" id="no" name="delete_it" value="no">
<label for="no">No</label>
</div><br>
<button type="submit">Delete Entry</button>
</form>
<% ELSE %>
<p>Invalid entry.</p>
<% END %>
</div>
anywhere we'd been using a parameter earlier now calls a corresponding method on the entry object. and we've also added a check to display a message if an invalid entry ID was provided. safety first, always π«Ά
now let's write the code to actually perform the delete:
post '/delete/:id[Int]' sub {
my $id = route_parameters->get('id');
my $entry = resultset( 'Entry' )->find( $id );
if( !$entry ) {
status 'not_found';
return "Attempt to delete non-existent entry $id";
}
# Always default to not destroying data
my $delete_it = body_parameters->get('delete_it') // 0;
if( $delete_it ) {
$entry->delete;
redirect uri_for "/";
} else {
# Display our entry again
redirect uri_for "/entry/$id";
}
};
this uses all of the same techniques and logic we've built previously, like:
- a route definition that checks the datatype of the ID
- a check to ensure a blog post with that ID exists
- the code to safely perform a delete
once a resultset object has been instantiated, calling the delete method removes it from the database.
- code to redirect to the entry display if the post isn't deleted
πΈ Implementation Recap πΈ
at this point, we've built an app that lets a user do the following things when writing a blog:
- creating an entry
- editing an existing entry
- deleting an entry
- listing all blog entries
- displaying a single entry
congratulations β you've added all the basic functionality! π now, let's secure critical functions of this blog by putting a login in front of them.
π Authentication π
now that the core functionality is done, we need to secure critical functions; visitors shouldn't be allowed to create or modify content, only authorized users of the Danceyland blog. no randoms touching the posts, bestie, that's the rule.
Dancer2 has several plugins available that help you add user authentication to your applications. for the Danceyland blog our needs are rather simple, so we'll use Dancer2::Plugin::Auth::Tiny as our authentication system of choice. iykyk π
Dancer2::Plugin::Auth::Tiny is the little helper that gives us some extra syntax so we can justβ¦ say which routes need a logged-in user and which ones are chill for anyone π It also hands us a bit of scaffolding to build the actual login procedure with. We'll circle back to that in a sec, bestie.
And we need a way to keep track of who is logged in. For that we gotta set up and work with sessions. β¨
πΈ Sessions πΈ
Sessions are how we sneak persistence into our web apps. They show up in a million different shapes β remembering who's currently logged in, remembering what you typed into a form across the pages of a multi-page form, all of it. Sessions = the mechanism for "remembering" stuff. π§ β¨
Sessions need a storage mechanism to power them. Some common storage engines are memory caches, files, and databases (SQL and NoSQL both, no cap).
And even though sessions are generally managed server side, you can also find them client side in secure cookies and browser local storage. π
For this tutorial we're using Dancer2's YAML session engine, Dancer2::Session::YAML. Keeping our sessions in YAML means it's easy to peek at what's inside a session while we're developing and debugging the blog. π
β¨ Setting Up a Session Engine β¨
Session engines work a lot like template engines: you do a lil bit of setup in your application's config file, and then they're justβ¦ available everywhere in the app. π
To set up the YAML session engine, add this to your config.yml:
session: "YAML"
engines:
session:
YAML:
cookie_name: dlblog.session
Heads up! You can only have one engines section, so this has to get combined with your existing template configuration. π«Ά
The section should now look like:
template: "template_toolkit"
session: "YAML"
engines:
template:
template_toolkit:
# Note: start_tag and end_tag are regexes
start_tag: '<%'
end_tag: '%>'
session:
YAML:
cookie_name: dlblog.session
π Storing and Retrieving Session Data π
We can use our session to store info across multiple requests. Iconic behaviour, honestly.
Store session data with the session keyword:
session user => 'admin';
And retrieving session data? Also the session keyword:
my $user = session 'user';
You can double check the username actually got written to the session by looking at the session file created on disk. If you peek in your project directory you'll notice a shiny new sessions/ directory. There should be exactly one file in there: your current session. Run this:
$ cat sessions/<some session id>.yml
And you'll get a file that looks like:
---
user: admin
The session filename matches the session ID, which lives in a cookie that gets delivered to the client browser whenever your application is accessed. If you've got browser dev tools open while you hit your development site, you can inspect that cookie and see it for yourself. π
YAML files are great for sessions while developing, but they are not a good choice for production. We'll look at some other options when we talk about deploying to production later in this tutorial. π
π Storing application users π
Since we're already using a database to store our blog contents, it just makes sense to track our application users there too. Let's create a super simplistic table to store user data in db/users.sql:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR NOT NULL UNIQUE,
password VARCHAR NOT NULL
);
Then run this from our shell:
sqlite3 db/dlblog.db < db/users.sql
By declaring password to be an unbounded varchar field, we allow for passwords or passphrases of any length. π And notice we don't track admin status, rights, or anything like that β if you can log in, you can administer the blog. That's it. That's the whole permission model. π
We'll need to regenerate the DBIx::Class::Result classes so we can create objects that represent users. Run this in your shell from the project directory:
dbicdump -o dump_directory=./lib \
-o components='["InflateColumn::DateTime"]' \
DLBlog::Schema dbi:SQLite:db/dlblog.db '{ quote_char => "\"" }'
You should now have an extra source file in your project directory: lib/DLBlog/Schema/Result/User.pm. β¨
π¦ Password management with Dancer2::Plugin::CryptPassphrase π¦
It is best practice to store passwords encrypted, lest someone with database access look at your users table and steal account credentials. Instead of rolling our own (please don't π), we'll use one of the many great options on CPAN.
Dancer2::Plugin::CryptPassphrase gives convenient access to Crypt::Passphrase inside your Dancer2 applications. We'll use the latter to generate a password hash for any new users we create.
Install the above modules:
cpanm Dancer2::Plugin::CryptPassphrase
and add the module to your cpanfile:
requires "Dancer2::Plugin::CryptPassphrase";
This extension needs config telling it which password encoder to use. Add this to the plugins section of the environments/development.yml file:
CryptPassphrase:
encoder:
module: Argon2
parallelism: 2
From your shell, running the following will produce an encrypted password string:
perl -MCrypt::Passphrase -E \
'my $auth=Crypt::Passphrase->new(encoder=>"Argon2"); say $auth->hash_password("test")'
(substitute any other password for "test" you'd rather use π)
That can then be filled in as the password value in the below SQL. From your shell:
sqlite3 db/dlblog.db
sqlite> INSERT INTO users (username, password)
VALUES (
'admin',
'$argon2id$v=19$m=262144,t=3,p=1$07krd3DaNn3b9JplNPSjnA$CiFKqjqgecDiYoV0qq0QsZn2GXmORkia2YIhgn/dbBo'
); -- admin/test
sqlite> .quit
π Dancer2::Plugin::Auth::Tiny π
We also need to install Dancer2::Plugin::Auth::Tiny. From your shell:
cpanm Dancer2::Plugin::Auth::Tiny
Then add this to your cpanfile:
requires "Dancer2::Plugin::Auth::Tiny";
π Implementing Login π
We need two routes to implement the login process: a GET route to display a login form, and a POST route to process the form data. But before all that, we need to include the plugins we need for authentication. Below your other use statements, add the following:
use Dancer2::Plugin::Auth::Tiny;
use Dancer2::Plugin::CryptPassphrase;
We'll need some HTML to create a login form. Add the following to a new template file, views/login.tt:
<div id="login">
<% IF login_error %>
<div>
Invalid username or password
</div>
<% END %>
<form method="post" action="<% request.uri_for('/login') %>">
<div>
<label for="username">Username</label>
<input type="text" name="username" id="username">
</div>
<div>
<label for="password">Password</label>
<input type="password" name="password" id="password">
</div>
<input type="hidden" name="return_url" id="return_url" value="<% return_url %>">
<button type="submit">Login</button>
</form>
</div>
Next, we need a route to display the login template:
get '/login' => sub {
template 'login' => { return_url => params->{ return_url } };
};
So here's the tea β if a user tries to access a route that requires a login and they aren't currently logged in, they get redirected to this get '/login' route, and the URL they originally tried to access gets stored in a generic parameter named return_url. After a successful login attempt, the user gets redirected to the page they were originally trying to gain access to. And return_url is stored in the form as a hidden field. π«Ά
Finally, when a login request gets submitted, we attempt to validate it via a POST route:
post '/login' => sub {
my $username = body_parameters->get('username');
my $password = body_parameters->get('password');
my $return_url = body_parameters->get('return_url');
my $user = resultset( 'User' )->find({ username => $username });
if ( $user and verify_password( $password, $user->password) ) {
app->change_session_id;
session user => $username;
info "$username successfully logged in";
return redirect $return_url || '/';
}
else {
warning "Failed login attempt for $username";
template 'login' => { login_error => 1, return_url => $return_url };
}
};
We read the form values passed as body parameters, and attempt to look up a user with the provided username using find() with a special invokation; this time we specifically want to attempt to find a single row based on the value provided to the username column. π―
If a user is found, we use the verify_password() function from Crypt::Passphrase (provided via Dancer2::Plugin::CryptPassphrase) to try to compare password hashes; the passwords themselves are never directly checked against one another. Instead, the password entered by the user gets hashed using the same algorithm as when the password was hashed and stored in the database. verify_password() takes two arguments: the password the user entered, and the password hash we previously saved (stored in $user->password). If the hashes match, we've validated our user and we can proceed to log them in. β
And this is where our session comes into play, bestie. We can store the name of the user that just authenticated in our session. Any time this user visits the site again, the session engine looks for a valid session with the ID provied in the cookie, and if a session is found, our application will look up the name of the user stored in that session; further activity will be tracked as that logged in user. π β¨
To accomplish this, we first should generate a new session ID as a matter of good practice. Since the level of security granted is changing, using a new session ID guarantees another browser or user that may have the same session ID isn't accidentally granted admin permissions to our app (this practice stops a whole class of attacks against your webapp). π₯
Next, we stash the logged in username in our session via the session keyword, log an audit message (at the info level) that says a user logged in, and finally redirect them to their intended location (or, back to the post listing by default). On future requests, this browser will be treated as being logged in as the provided username. π
If the password check isn't successful, best practice is to log a message saying a login attempt failed, then redisplaying the login page with an error.
πΈ Implementing Logout πΈ
To logout a user, we need to remove the session for the logged in user, which creates a new session (containing no information about a logged in user) and issues a new cookie with the new session ID.
All that is needed is a simple GET route:
get '/logout' => sub {
app->destroy_session;
redirect uri_for "/";
};
In this route, we ask Dancer2 to destroy the user's session, then redirect them to the landing page. When the landing page request is served, a new session is created, and a new cookie delivered to the user's browser.
bye bestie, session deleted, cookie reissued, no notes π
π Securing Routes π
We earlier established that blog maintenance capabilities should be restricted to logged in/authenticated admin users. For the Danceyland blog, this amounts to:
- Create
- Update
- Delete
Dancer2::Plugin::Auth::Tiny provides us with a little syntactic sugar to make this happen: needs login.
You can decorate the appropriate routes as such:
get '/create' => needs login => sub {
This tells Dancer2 that when this route is accessed, if we don't have a logged in user, redirect them to the /login route.
fr fr this is the whole security model, one little needs login and the gate is UP π«Ά
β¨ Recap β¨
Your finished application code should look like this:
package DLBlog;
use Dancer2;
use Dancer2::Plugin::DBIx::Class;
use Dancer2::Plugin::Auth::Tiny;
use Dancer2::Plugin::CryptPassphrase;
use Feature::Compat::Try;
get '/' => sub {
# Give us the most recent first
my @entries = resultset('Entry')->search(
{},
{ order_by => { -desc => 'created_at' } },
)->all;
template 'index', { entries => \@entries };
};
get '/entry/:id[Int]' => sub {
my $id = route_parameters->get('id');
my $entry = resultset( 'Entry' )->find( $id );
template 'entry', { entry => $entry };
};
get '/create' => needs login => sub {
# Vars are passed to templates automatically
template 'create_update', { post_to => uri_for '/create' };
};
post '/create' => needs login => sub {
my $params = body_parameters();
var $_ => $params->{ $_ } foreach qw< title summary content >;
my @missing = grep { $params->{$_} eq '' } qw< title summary content >;
if( @missing ) {
var missing => join ",", @missing;
warning "Missing parameters: " . var 'missing';
forward '/create', {}, { method => 'GET' };
}
my $entry = do {
try {
resultset('Entry')->create( $params->as_hashref );
}
catch( $e ) {
error "Database error: $e";
var error_message => 'A database error occurred; your entry could not be created',
forward '/create', {}, { method => 'GET' };
}
};
debug 'Created entry ' . $entry->id . ' for "' . $entry->title . '"';
redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
};
get '/update/:id[Int]' => needs login => sub {
my $id = route_parameters->get('id');
my $entry = resultset( 'Entry' )->find( $id );
var $_ => $entry->$_ foreach qw< title summary content >;
template 'create_update', { post_to => uri_for "/update/$id" };
};
post '/update/:id[Int]' => needs login => sub {
my $id = route_parameters->get('id');
my $entry = resultset( 'Entry' )->find( $id );
if( !$entry ) {
status 'not_found';
return "Attempt to update non-existent entry $id";
}
my $params = body_parameters();
var $_ => $params->{ $_ } foreach qw< title summary content >;
my @missing = grep { $params->{$_} eq '' } qw< title summary content >;
if( @missing ) {
var missing => join ",", @missing;
warning "Missing parameters: " . var 'missing';
forward "/update/$id", {}, { method => 'GET' };
}
try {
$entry->update( $params->as_hashref );
}
catch( $e ) {
error "Database error: $e";
var error_message => 'A database error occurred; your entry could not be updated',
forward "/update/$id", {}, { method => 'GET' };
}
debug 'Updated entry ' . $entry->id . ' for "' . $entry->title . '"';
redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
};
get '/delete/:id[Int]' => needs login => sub {
my $id = route_parameters->get('id');
my $entry = resultset( 'Entry' )->find( $id );
template 'delete', { entry => $entry };
};
post '/delete/:id[Int]' => needs login => sub {
my $id = route_parameters->get('id');
my $entry = resultset( 'Entry' )->find( $id );
if( !$entry ) {
status 'not_found';
return "Attempt to delete non-existent entry $id";
}
# Always default to not destroying data
my $delete_it = body_parameters->get('delete_it') // 0;
if( $delete_it ) {
$entry->delete;
redirect uri_for "/";
} else {
# Display our entry again
redirect uri_for "/entry/$id";
}
};
get '/login' => sub {
template 'login' => { return_url => params->{ return_url } };
};
post '/login' => sub {
my $username = body_parameters->get('username');
my $password = body_parameters->get('password');
my $return_url = body_parameters->get('return_url');
my $user = resultset( 'User' )->find({ username => $username });
if ( $user and verify_password( $password, $user->password) ) {
app->change_session_id;
session user => $username;
info "$username successfully logged in";
return redirect $return_url || '/';
}
else {
warning "Failed login attempt for $username";
template 'login' => { login_error => 1, return_url => $return_url };
}
};
get '/logout' => sub {
app->destroy_session;
redirect uri_for "/";
};
true;
LOOK AT HER. the whole app. she's beautiful. she ATE π
π Finishing Touches π
At this point, Danceyland has a fully functional blog engine, and your users are chomping at the bit to start using it. Before we let them loose on your new creation, let's pretty it up, write some tests, and get it deployed to a production environment.
π Adding some style π
We've written all of our views up to this point with basic HTML; no effort has been made to style our blog or make it attractive. That is beyond the scope of this tutorial.
If you clone the tutorial repository, you'll notice that all views have styling markup that isn't provided in the tutorial. This was done to show you one way styling could be done, and to give you an attractive example application to learn from.
Bootstrap <https://getbootstrap.com> was used to provide styling for the tutorial; it's well known, easy to understand, and easy to find help for. If you want to learn more about how Bootstrap works, check out their website for more details.
ngl the tutorial views are serving plain-HTML minimalism and honestly? valid. the repo version is the glow-up β¨
π§ͺ Testing Your Application π§ͺ
When developing your application, writing and running tests helps you work through difficult parts of your application, and allows you to verify that your Dancer2 application is behaving as you designed it to. In a production environment, running tests before installation or upgrade help ensure there are no surprises when you start/restart the application server. Let's see how we can write some basic functionality tests.
π¦ Using Test::WWW::Mechanize::PSGI π¦
Test::WWW::Mechanize::PSGI lets us test our Dancer2 applications without having to spin up a test web server. It is loaded with convenience methods to make it easy to navigate and test our Plack/PSGI applications. Run the following to install this from your shell:
cpanm Test::WWW::Mechanize::PSGI
It must also be added to the on "test" section of your cpanfile:
requires "Test::WWW::Mechanize::PSGI" => "0";
Before we do any testing for our blog, we'll need to create some testing infrastructure so we don't step on any of our production or test data. We need a testing database to run tests against so we don't corrupt other development or production data. In your shell, run the following:
mkdir t/db
cp db/dlblog.db t/db/test.db
(or, you can use the test database that ships with the DLBlog GitHub repository)
You'll also need a config file that is representative of your testing environment. Create environments/test.yml and add the following:
logger: "console"
log: "warning"
show_stacktrace: 0
no_server_tokens: 1
plugins:
DBIx::Class:
default:
dsn: "dbi:SQLite:dbname=t/db/test.db"
schema_class: "DLBlog::Schema"
dbi_params:
RaiseError: 1
AutoCommit: 1
CryptPassphrase:
encoder:
module: Argon2
parallelism: 2
This is a hybrid of your production and development configs:
- It still logs to the console, but at a higher level so only errors are seen
- We hide any stacktraces and disable server tokens/headers
- We tell DBIx::Class to look at our test database
To run the default tests from your shell:
DANCER_ENVIRONMENT=test prove -lv
This runs all tests in the t/ subdirectory with maximum verbosity, and runs with our test environment configuration.
Dancer2 creates applications with two default tests. The first, t/001_base.t, ensures that your Dancer2 application compiles successfully without errors. The other test, t/002_index_route.t, ensures that the default route of your application is accessible. These are the two most basic tests an application can have, and validate very little about the functionality of your app. We're going to make two new tests: one just to test the nuances of the login process (as security is critical), and another to test basic blog functionality.
π₯ Login Tests π₯
Let's verify the login process behaves as intended. Edit t/003_login.t and add the following:
use strict;
use warnings;
use Test::More;
use Test::WWW::Mechanize::PSGI;
security testing is the moment, bestie β we do NOT ship a login route untested π«Ά
use DLBlog;
my $mech = Test::WWW::Mechanize::PSGI->new(
app => DLBlog->to_app,
);
$mech->get_ok('/create', 'Got /create while not logged in');
$mech->content_contains('Password', '...and was presented with a login page');
$mech->submit_form_ok({
fields => {
username => 'admin',
password => 'foobar',
}}, '...which we gave invalid credentials');
$mech->content_contains('Invalid username or password', '...and gave us an appropriate error');
$mech->submit_form_ok({
fields => {
username => 'admin',
password => 'test',
}}, '...so we give it real credentials');
$mech->content_contains('form', '...and get something that looks like the create form' );
$mech->content_contains('Content', 'Confirmed this is the create form');
done_testing;
okay so we're pulling in two test modules here: Test::More, which gives us the basic β¨test functionalityβ¨, and Test::WWW::Mechanize::PSGI, which is the one doing all our heavy lifting ποΈπ
First things first, bestie β we gotta make an instance of a Mechanize object:
my $mech = Test::WWW::Mechanize::PSGI->new(
app => DLBlog->to_app,
);
That builds us an instance of the Mechanize user agent π¦ and points it straight at an instance of the Danceyland blog app (DLBlog).
So remember how we said some routes can't be accessed by unauthorized / non-logged-in users? Yeah. Let's actually test that π
$mech->get_ok('/create', 'Got /create while not logged in');
$mech->content_contains('Password', '...and was presented with a login page');
This is testing the "needs login" condition on the /create route. If we haven't logged in, we should get yeeted over to a login page πͺ get_ok makes sure the route is actually accessible, and content_contains goes hunting for a password field. iconic, truly π
And if a login attempt fails we should get an error message back. So let's stuff the form with invalid credentials and verify:
$mech->submit_form_ok({
fields => {
username => 'admin',
password => 'foobar',
}}, '...which we gave invalid credentials');
$mech->content_contains('Invalid username or password', '...and gave us an appropriate error');
submit_form_ok takes a hashref of fields, drops the values you gave it into them, then clicks the right submit button π±οΈβ¨ Then we check the resulting page content to confirm that yes, fr fr, the invalid username/password error message is right there.
So we know login handles failed attempts okay now. But what about a login with valid credentials? π
$mech->submit_form_ok({
fields => {
username => 'admin',
password => 'test',
}}, '...so we give it real credentials');
$mech->content_contains('form', '...and get something that looks like the create form' );
$mech->content_contains('Content', 'Confirmed this is the create form');
We pass the default admin/test credentials π then peep the page we get taken to. The create page should have a form, and one of those fields should be named Content. content_contains looks for both of those on the resulting page and passes if they're there. slay.
And finally, we gotta announce that we're done running tests:
done_testing;
Now let's run just this one test. From your shell:
DANCER_ENVIRONMENT=test prove -lv t/003_login.t
And you should see the following output:
t/003_login.t ..
ok 1 - Got /create while not logged in
ok 2 - ...and was presented with a login page
[DLBlog:2287783] warning @2025-02-06 22:34:25> Failed login attempt for admin in /path/to/DLBlog/lib/DLBlog.pm l. 134
ok 3 - ...which we gave invalid credentials
ok 4 - ...and gave us an appropriate error
ok 5 - ...so we give it real credentials
ok 6 - ...and get something that looks like the create form
ok 7 - Confirmed this is the create form
1..7
ok
All tests successful.
Files=1, Tests=7, 1 wallclock secs ( 0.02 usr 0.00 sys + 0.92 cusr 0.10 csys = 1.04 CPU)
Result: PASS
Those little labels help us see which tests are running, which makes examining failures way easier when they happen π You'll also spot a log message that got produced when our login attempt failed, plus a big old PASS at the end showing all the tests ran successfully. we ate and left no crumbs π
π¦ Blog Tests π¦
Using all the same techniques we just learned writing the login test, we're gonna make a test for basic blog functionality β¨ The complete test is gonna run through a basic workflow of the Danceyland blog:
- Login
- Create an entry
- Edit an entry
- Delete an entry
Add the following to t/004_blog.t in your project directory:
use strict;
use warnings;
use Test::More;
use Test::WWW::Mechanize::PSGI;
use DLBlog;
my $mech = Test::WWW::Mechanize::PSGI->new(
app => DLBlog->to_app,
);
subtest 'Landing page' => sub {
$mech->get_ok('/', 'Got landing page');
$mech->title_is('Danceyland Blog', '...for our blog');
$mech->content_contains('Test Blog Post','...and it has a test post');
};
subtest 'Login' => sub {
$mech->get_ok('/login', 'Visit login page to make some changes');
$mech->submit_form_ok({
fields => {
username => 'admin',
password => 'test',
}}, '...so we give it a user');
};
subtest 'Create' => sub {
$mech->get_ok('/create', 'Write a new blog post');
$mech->submit_form_ok({
fields => {
title => 'Another Test Post',
summary => 'Writing a blog post can be done by tests too',
content => 'You can create blog entries programmatically with Perl!',
}}, '...then write another post');
$mech->base_like( qr</entry/\d+$>, '...and get redirected to the new entry' );
};
my $entry_id;
subtest 'Update' => sub {
($entry_id) = $mech->uri =~ m</(\d+)$>;
$mech->get_ok("/update/$entry_id", 'Navigating to the update page for this post');
$mech->submit_form_ok({
fields => {
title => 'Yet ANOTHER Test Post',
}}, '...then update yet another post');
$mech->base_like( qr</entry/${entry_id}$>, '...and get redirected to the entry page' );
$mech->has_tag('h1','Yet ANOTHER Test Post', '...and it has the updated title');
};
subtest 'Delete' => sub {
$mech->get_ok("/delete/$entry_id", "Go delete page for new entry");
$mech->submit_form_ok({
fields => {
delete_it => 'yes',
}}, '...then delete it!');
$mech->get_ok("/entry/$entry_id", '...then try to navigate to the entry');
$mech->content_contains('Invalid entry','...and see the post is no longer there');
};
done_testing;
So you'll notice that this time we organized our tests into multiple subtests that keep related tests together π«Ά Is it strictly necessary? No bestie, but it's highkey helpful when you're working on big test files π
This test introduces us to a few additional types of tests:
base_likeβ this test method examines the resulting URL from the previous Mechanize operation. In theCreatesubtest we submit a blog entry, and we should get taken to the view page for that new entry. By checking the resulting URL we can verify that yeah, we were taken to the view page after the post got created πuriβ this returns the full URI of the previous Mechanize operation. From that URI we can extract the ID of the last blog entry we created. We're gonna need that to test the update and delete operations πhas_tagβ this method looks for a tag matching the type specified, and checks whether the text inside that tag matches what the test specifies. Ourh1on the entry page should have the blog post title, so in the update test we check that the updated title is present. periodt β¨
Run the test from your shell:
DANCER_ENVIRONMENT=test prove -lv t/004_blog.t
And you should see the following output:
t/004_blog.t ..
# Subtest: Landing page
ok 1 - Got landing page
ok 2 - ...for our blog
ok 3 - ...and it has a test post
1..3
ok 1 - Landing page
# Subtest: Login
ok 1 - Visit login page to make some changes
ok 2 - ...so we give it a user
1..2
ok 2 - Login
# Subtest: Create
ok 1 - Write a new blog post
ok 2 - ...then write another post
ok 3 - ...and get redirected to the new entry
1..3
ok 3 - Create
# Subtest: Update
ok 1 - Navigating to the update page for this post
ok 2 - ...then update yet another post
ok 3 - ...and get redirected to the entry page
ok 4 - ...and it has the updated title
1..4
ok 4 - Update
# Subtest: Delete
ok 1 - Go delete page for new entry
ok 2 - ...then delete it!
ok 3 - ...then try to navigate to the entry
ok 4 - ...and see the post is no longer there
1..4
ok 5 - Delete
1..5
ok
All tests successful.
Files=1, Tests=5, 1 wallclock secs ( 0.01 usr 0.01 sys + 0.77 cusr 0.10 csys = 0.89 CPU)
Result: PASS
You'll notice that not only is the code conveniently grouped by subtest, but so is the output π¦ the way that just works.
There's a lot more you can still test, ngl. Look for some additional ideas at the end of this tutorial π
π DEPLOYMENT π
Okay bestie, we built the whole thing, we wrote tests to make sure it doesn't fall apart, and now?? Now we make it public. Like, on a server. For real people. Let's go β¨
π Creating Production Configuration π
So the default Dancer2 config is super chatty β it hands the developer tons of info to help with debugging while you're building stuff. But in production? That's way too much info, and some of it is exactly the kind of thing someone trying to compromise your app would love to see. π So let's make an environment specifically for production and turn the detail dial all the way down.
Also, quick note: if you were using a database server instead of SQLite, you'd want to update your database credentials in your production config so they point at your production database server. Just saying. π
Replace your environments/production.yml file with this:
# configuration file for production environment
behind_proxy: 1
# only log info, warning and error messsages
log: "info"
# log message to a file in logs/
logger: "file"
# hide errors
show_stacktrace: 0
# disable server tokens in production environments
no_server_tokens: 1
# Plugin configuration
plugins:
DBIx::Class:
default:
dsn: "dbi:SQLite:dbname=db/dlblog.db"
schema_class: "DLBlog::Schema"
dbi_params:
RaiseError: 1
AutoCommit: 1
CryptPassphrase:
encoder:
module: Argon2
parallelism: 2
So what did we just change? Let's break it down, because each of these is iconic in its own way:
- Running behind a reverse proxy β We're gonna deploy our app alongside NGINX, and running that way means Dancer2 has to interact with HTTP headers differently than it would if it were standing alone. This setting tells Dancer2 to behave the way it should when it's sitting behind an NGINX (or any other) reverse proxy. Teamwork, babes. π€
- Logging only informational or more severe messages β In a production environment, logging debug messages and core Dancer2 messages is rarely needed. Like, nobody's reading that at 3am.
- Logging to a file β Servers run in the background, not in a console window, bestie. So we need somewhere to catch those log messages. Bonus: file logs can be shipped off to another service (hi, Kibana) for analysis. π
- No stacktraces β If a fatal error happens, the stacktraces Dancer2 produces hand a potential attacker a little guided tour of the insides of your application. π By setting
show_stacktraceto0, all errors show only thepublic/500.htmlpage. Nothing to see here, move along. - Disabling server tokens β Setting
no_server_tokensstops Dancer2 from adding theX-Powered-Byheader and, when you're using Dancer2's standalone server, from setting theServerheader to the Dancer2 version.
Heads up: it does not touch the Server header when running under an external PSGI server.
And our plugin configuration? Unchanged from the development environment. Same vibes, different environment. π¦
π¦ Installing the Danceyland Blog π¦
On your production server, make sure you've got a version of Perl installed, then copy your project files over by whatever method makes sense for your situation. (Scp? Rsync? Git? Carrier pigeon? You do you. ποΈ)
Run this from inside the project directory to install the application's dependencies:
cpanm --installdeps . --with-test --with-all-features
Now go get a cup of coffee β while that churns β when you come back, resolve any dependency issues, then run your tests to make sure the Danceyland blog is actually functional:
DANCER_ENVIRONMENT=test prove -l
Your output should look something like this:
./t/001_base.t ......... ok
./t/002_index_route.t .. ok
./t/003_login.t ........ 1/? [DLBlog:2291513] warning @2025-02-06 22:59:09> Failed login attempt for admin in /path/to/DLBlog/lib/DLBlog.pm l. 134
./t/003_login.t ........ ok
./t/004_blog.t .........
# Subtest: Landing page
./t/004_blog.t ......... 1/? # Subtest: Login
./t/004_blog.t ......... 2/? # Subtest: Create
# Subtest: Update
# Subtest: Delete
./t/004_blog.t ......... ok
All tests successful.
Files=4, Tests=15, 3 wallclock secs ( 0.02 usr 0.00 sys + 2.30 cusr 0.32 csys = 2.64 CPU)
Result: PASS
Result: PASS?? We love to see it. π₯
π§ Deploying with a PSGI Server π§
Okay so plackup β by default β runs a development server for developing your application. It is not suitable for any public-facing deployment. Do not do it. I'm serious. π
There are a bunch of great options over on the Plack <https://plackperl.org/> website. For our example we'll use Starman, because it gives reasonable performance and works on nearly any server OS.
We'll pair Starman with Server::Starter, which gives you a robust way to manage server processes. Iconic combo. π
Install both of these modules:
cpanm Starman Server::Starter
And add them to the blog's cpanfile:
requires "Starman";
requires "Server::Starter";
Assuming we're deploying to a Debian server, you can use the following to start the Danceyland blog in the background:
sudo start_server \
--daemonize \
--dir=/path/to/DLBlog \
--port=5000 \
--log-file=/var/log/dlblog.log \
--pid-file=/var/run/dlblog.pid \
--status-file=/var/run/dlblog.status \
-- plackup -s Starman--user www-data --group www-data -E production \
bin/app.psgi
Once it's operational, the server can be restarted with:
start_server --restart --pid-file=/var/run/dlblog.pid --status-file=/var/run/dlblog.status
Or stopped with:
start_server --stop --pid-file=/var/run/dlblog.pid
Start, restart, stop β full control, no chaos. β¨
π Configuring Reverse Proxy with NGINX π
Finally, let's put NGINX out in front of our Dancer2 application. This improves the security and the performance of our application. Glow up. π
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
If this server is public facing, you should also configure it with HTTPS β https://letsencrypt.org in Let's Encrypt makes this free and easy. Free TLS? Say less. π
More advanced setups are totally possible (like serving static content from NGINX instead of Dancer2), but that's beyond the scope of this tutorial. We stay on topic here. π
π WHAT YOU'VE BUILT π
Congratulations!!! π You have built a primitive but very functional blog engine that protects maintenance functions behind a user login, using Dancer2 and other plugins and modules in its ecosystem. You've learned a bunch of important building blocks that are gonna be crucial for building other applications. Look at you go. π₯Ή
π¦ WHERE TO GO NEXT π¦
This application can be used as a springboard and a reference for future Dancer2 projects. There are still a number of improvements and additional features that can be added to this blog. A few ideas, bestie:
- Paginate the list of blog entries β After a while, the list of blog entries can get long. Using
Data::Pageor other Perl modules, break the list of entries into reasonable page sizes so it's easier and quicker to navigate. - Add a search function to the blog β Add a search bar to the UI, then use the
search()method in DBIx::Class to find blog entries based on what the used input. - Improve application security by sanitizing input parameters β Using a regex or a validation framework (such as
Dancer2::Plugin::DataTransposeValidator), scrub all input before using it in a database operation. - Use database slugs in URLs instead of IDs β A database slug is a human-readable identifier (such as a URL encoding of the entry title) that can be used to identify an entry rather than the numerical ID. They're easier to remember than IDs, they're better for SEO of your content, and they make your application more secure by hiding some database implementation details from an attacker (such as a row ID).
- Move business logic to business layer; call business object from Dancer2 β In larger applications, business logic (like creating a blog post) may be put in an object (such as
DBIx::Class::ResultorDBIx::Class::ResultSetobjects, or other Moo or Moose objects), and that object gets instantiated and called from Dancer2. This helps to decouple tasks in an application, and allows for better testing of business logic. - Write more tests to catch error conditions for our blog β What happens when you pass a string to a route expecting an ID? What happens when you don't fill out all the fields on the create or update pages? Write tests to check these conditions using what you've learned from writing the existing tests, and make sure the error handling behaves as expected.
For another example of a blog engine in Dancer2, check out Dancer2::Plugin::LiteBlog from our project founder, Sukria. π«Ά
Happy Dancing! ππΊ
β¨ AUTHOR β¨
Dancer Core Developers
π COPYRIGHT AND LICENSE π
This software is copyright (c) 2026 by Alexis Sukrieh.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.
And that's the whole tutorial, bestie!! πΈ This was perl v5.42.3's rendition, dated 2026-09-20, and honestly? It ate. Go build something gorgeous with Dancer2 β you've got the whole toolbox now. Happy Dancing, fr fr. πππ¦
π¦ where this page comes from
- fedora 44: perl-Dancer2 2.2.1-1.fc44 Β· GPL-1.0-or-later OR Artistic-1.0-Perl
- π girlified by deepseek-ai/DeepSeek-V4.1-Flash on : a language model's rewrite of the original, under its terms