Today I am going to build a web application. I will document the process, step-by-step, so that an eager reader can follow along and create the application themselves. For those using this guide as instructions to follow, you will want to take time to prepare a development environment. I have documented how I do so in this guide, and codified the process here.
House News Wire will be a webapp that allows you to post status messages or news announcements. Only registered and logged in people will be able to read messages. People will be able to mark messages as read so that they no longer appear.
Thinking through the functionality that I need, at least the following capabilities will need to be developed.
This list of five items is all that my website needs to do. I need to make an interface for all of these things, what people actually click and type into. I'll sketch out the basic design and layouts that I'll need to build so I understand what pages are required.
I'll need one page that the user can login on. Another that the person can register on. Finally, one page to display the content and allow them to post messages and mark messages as read.
Now I have a general idea of the pages and functionality I am going to build. I have a shell open with my development environment. I'll begin building.
The first milestone I am aiming for is getting an index page to display in my web browser. However, there is some boiler-plate I will need to do first to set up the tools to start building from my fresh development server.
I'll run the following commands to get my initial project environment set up. The cpanm only needs to be run the first time I log in to my user account after creating the development server.
cpanm App::plx IO::Socket::SSL
plx --userinit
mkdir -p HouseNewsWire/Web
cd HouseNewsWire
git init
cd Web
plx --init
plx --cpanm -ldevel Carton
What I just did was to install plx, a tool that will keep my development environment contained so that additional libraries I need to install will install to my project directory instead of to the system itself. I initalized the environment for my user with plx --userinit. I installed IO::Socket::SSL as well so that some tools that use SSL will be able to use it.
After this I created the directories HouseNewsWire and HouseNewsWire/Web. The first will be my whole project, so I make a git repo starting there with git init. The webapp will be kept in HouseNewsWire/Web and I'll be starting there so I go into that directory and initialize a ~/.plx directory with plx --init.
When all is said and done, the directories ~/HouseNewsWire/.git and ~/HouseNewsWire/Web/.plx exist, and I am working in ~/HouseNewsWire/Web
I need to start installing the web frameworks and tooling that I'll need to build the webapp. I can list all of the modules I want to have installed in a cpanfile, so I will create that.
requires "Catalyst::Runtime";
requires "Catalyst::Plugin::ConfigLoader";
requires "Catalyst::Plugin::Static::Simple";
requires "Catalyst::Action::RenderView";
requires "Catalyst::Plugin::Static::Simple";
requires "Catalyst::View::Xslate";
requires "Catalyst::Plugin::Session";
requires "Catalyst::Plugin::Session::State::Cookie";
requires "Catalyst::Plugin::Session::Store::Cookie";
requires "Catalyst::Model::DBIC::Schema";
requires "Starman";
requires "DateTime::Format::Pg";
requires "Try::Tiny";
These modules make up a lot of what I'll use to build this project. Each module adds some functionality that I can use. The first one, Catalyst::Runtime is the module that makes up the framework I am building this in, Catalyst; each other beginning with Catalyst:: adds functionality to that framework. Starman is an HTTP server for PSGI apps like what I am building. In production I'll put nginx in front of it, but for development I'll use it directly.
Now that I have this file cpanfile in ~/HouseNewsWire/Web and I'm in that directory, I will install these modules:
symkat@devel:~/HouseNewsWire/Web$ plx carton install
Installing modules using /home/symkat/HouseNewsWire/Web/cpanfile
Successfully installed Module-Build-0.4231
Successfully installed Module-Runtime-0.016
...
202 distributions installed
Complete! Modules were installed into /home/symkat/HouseNewsWire/Web/local
All of my modules have been installed now. If I look at my Web directory, I have cpanfile, the file I created. I also have a directory devel/, that's where I installed Carton and where I will install modules used to develope HouseNewWire. The directory local/ is where Carton installed all of my dependancies and where I will install modules used by HouseNewsWire that will be required in production. There is one last file, cpanfile.snapshot and it was generated by Carton when I installed the modules.
Now it is time to build the skeleton. I want to get to creating that index page, first I'll need to create the module that runs the entire webapp. I'll be naming this HouseNewsWire::Web so I create the file lib/HouseNewsWire/Web.pm.
symkat@devel:~/HouseNewsWire/Web$ mkdir -p lib/HouseNewsWire/
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web.pm
package HouseNewsWire::Web;
use Moose;
use namespace::autoclean;
use Catalyst::Runtime 5.80;
use Catalyst qw| -Debug Static::Simple |;
extends 'Catalyst';
our $VERSION = '0.01';
__PACKAGE__->config(
name => 'HouseNewsWire::Web',
enable_catalyst_header => 1, # Send X-Catalyst header
encoding => 'UTF-8', # Setup request decoding and response encoding
);
__PACKAGE__->config(
# Disable deprecated behavior needed by old applications
disable_component_resolution_regex_fallback => 1,
);
__PACKAGE__->config(
'View::Xslate' => {
path => [qw( root )],
suffix => 'tx',
syntax => 'Metakolon',
},
);
__PACKAGE__->setup(); # Start the webapp.
This module is the entry point into our application. Each __PACKAGE__->config statement is providing configuration information. Where it has View::Xslate, this configuration is going to be provided to our view called Xslate. That module will provide templating, so I am going to set it up now.
symkat@devel:~/HouseNewsWire/Web$ mkdir -p lib/HouseNewsWire/Web/View
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web/View/Xslate.pm
package HouseNewsWire::Webapp::View::Xslate;
use Moose;
extends qw( Catalyst::View::Xslate );
__PACKAGE__->meta->make_immutable;
Now my view is complete. Really I have just subclassed Catalyst::View::Xslate. My configuration in Web.pm will use files off of root/ for its templates, and I will use the Metakolon syntax.
Now I will create an index page.
symkat@devel:~/HouseNewsWire/Web$ mkdir root/
symkat@devel:~/HouseNewsWire/Web$ vim root/index.tx
<HTML>
<HEAD>
<TITLE>HELLO WORLD</TITLE>
</HEAD>
<BODY>
<p>Congratulations, this worked!</p>
</BODY>
</HTML>
I need to add one more file before I can test it. Remember how I said Starman would load this and run it? I need to tell it how, so I'll create a file app.psgi. The $app that is returned at the end is the PSGI app that Starman will run.
#!/usr/bin/env perl
use strict;
use warnings;
use HouseNewsWire::Web;
my $app = HouseNewsWire::Web
->apply_default_middlewares(HouseNewsWire::Web->psgi_app);
$app;
Now with everything in place, I can run the web server. By default it will bind to port 5000 on all IPs on the machine.
symkat@devel:~/HouseNewsWire/Web$ plx starman
2021/04/19-16:10:12 Starman::Server (type Net::Server::PreFork) starting! pid(20158)
Resolved [*]:5000 to [0.0.0.0]:5000, IPv4
Binding to TCP port 5000 on host 0.0.0.0 with IPv4
Setting gid to "1000 1000 27 998 1000"
...
[info] HouseNewsWire::Web powered by Catalyst 5.90128
When this loads up, you will see debug information about all configured routes and middleware. It will be a lot of information. Each time a request is made -- when you click to view the app -- you'll get information in this window about what is happening.
Now when I load the app I see...
Well, that's silly. There is no index page. I forgot to write a controller for it!
I need to write a controller and I will call the first one Root.
symkat@devel:~/HouseNewsWire/Web$ mkdir lib/HouseNewsWire/Web/Controller
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web/Controller/Root.pm
package HouseNewsWire::Web::Controller::Root;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller' }
__PACKAGE__->config(namespace => '');
sub base :Chained('/') PathPart('') CaptureArgs(0) {
my ( $self, $c ) = @_;
}
sub index :Chained('base') PathPart('') Args(0) {
my ( $self, $c ) = @_;
}
sub end :ActionClass('RenderView') { }
__PACKAGE__->meta->make_immutable;
I'm making a controller so the first five lines here are boilerplate. __PACKAGE__->config here is because I am in the root controller. Normally the namespace is found through the module name, so paths might be assumed to be root/ for this module. Configuring it with a blank namespace ensures this doesn't happen.
The first function might look a bit odd. Everything to the the right of the : are method attributes. Each one offers some configuration.
I am using Chained Dispatching. One of these functions, base is a point in the chain. It starts matching at /. That could be extended by filling in PathPart, but it's kept empty. CaptureArgs will capture some amount of path segments after itself. I select to capture no path segments.
The next function index can only be gotten to once you've gotten to base, because it is chained off of it. I don't capture any more arguments, and I don't add a PathPart. Notice that it is named Args here instead of CaptureArgs. CaptureArgs is the form to use if you are writing a link in the chain, while Args must be used if your function is the final destination.
I have one last function, end. This function will be called at the end of the request cycle. Using ActionClass('RenderView') will pass control over from my Controller here to the View layer to render the web page.
With this in place, I'll try that again.
symkat@devel:~/HouseNewsWire/Web$ plx starman
2021/04/19-17:12:04 Starman::Server (type Net::Server::PreFork) starting! pid(20564)
Resolved [*]:5000 to [0.0.0.0]:5000, IPv4
Binding to TCP port 5000 on host 0.0.0.0 with IPv4
Setting gid to "1000 1000 27 998 1000"
...
[info] HouseNewsWire::Web powered by Catalyst 5.90128
It works!
In my terminal I see the request come in
[info] HouseNewsWire::Web powered by Catalyst 5.90128
[info] *** Request 1 (0.028/s) [20566] [Mon Apr 19 17:12:43 2021] ***
[debug] Path is "/index"
[debug] "GET" request for "/" from "172.16.32.56"
[debug] Response Code: 200; Content-Type: text/html; charset=utf-8; Content-Length: unknown
[info] Request took 0.008029s (124.549/s)
.------------------------------------------------------------+-----------.
| Action | Time |
+------------------------------------------------------------+-----------+
| /base | 0.000194s |
| /index | 0.000059s |
| /end | 0.001257s |
| -> HouseNewsWire::Web::View::Xslate->process | 0.000525s |
'------------------------------------------------------------+-----------'
This is excellent! I can see that a request for / came in. The actions /base, /index, and /end were called, and /end passed control to our view, HouseNewsWire::Web::View::Xslate. That ultimately returned the HTML that my browser rendered.
I need users to be able to register accounts. The website will require login to view messages, so users are a requirement. This will require I handle a few different things.
The first thing I will do is add a blank bootstrap template to root/register.tx
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6"
crossorigin="anonymous">
<!-- Title -->
<title>HouseNewsWire - Register</title>
</head>
<body>
<div class="container">
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf"
crossorigin="anonymous">
</script>
</body>
</html>
Now I'll add the controller for this page so that the template will render. I update lib/HouseNewsWire/Web/Controller/Root.pm to include the following function after sub index
sub register :Chained('base') PathPart('register') Args(0) {
my ( $self, $c ) = @_;
}
I'm still chained off of base, but this function will be matched when the path contains register. I'm going to try running it now and see if I have a register page.
symkat@devel:~/HouseNewsWire/Web$ plx starman
2021/04/19-17:51:32 Starman::Server (type Net::Server::PreFork) starting! pid(21084)
Resolved [*]:5000 to [0.0.0.0]:5000, IPv4
Binding to TCP port 5000 on host 0.0.0.0 with IPv4
Setting gid to "1000 1000 27 998 1000"
...
[info] HouseNewsWire::Web powered by Catalyst 5.90128
[info] *** Request 1 (0.017/s) [21090] [Mon Apr 19 17:52:34 2021] ***
[debug] Path is "/register"
[debug] "GET" request for "register" from "172.16.32.56"
[debug] Response Code: 200; Content-Type: text/html; charset=utf-8; Content-Length: unknown
[info] Request took 0.008104s (123.396/s)
.------------------------------------------------------------+-----------.
| Action | Time |
+------------------------------------------------------------+-----------+
| /base | 0.000156s |
| /register | 0.000057s |
| /end | 0.001134s |
| -> HouseNewsWire::Web::View::Xslate->process | 0.000484s |
'------------------------------------------------------------+-----------'
When I visit the webpage, I see a blank page with the title and if I view source I see the expected HTML.
While I am doing this, I really ought to include a navigation bar. I'm going to extend the template to include a navigation bar.
...
<!-- Title -->
<title>HouseNewsWire - Register</title>
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="/">House News Wire</a>
<div class="navbar-nav justify-content-end" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/auth/register">Register</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf"
crossorigin="anonymous">
</script>
...
Now this template file has a lot of code in it, but it's pretty boilerplate and will be repeated a lot. I'm going to break that entire navigation section out and put it into a file called _navbar.tx Then I will update register.tx
<body>
<div class="container">
[% include '_navbar.tx' %]
</div>
When I check the page again, everything is still looking exactly the same. The thing is, when I add content to this I'm really only going to want to put it inside that div, under the navigation bar. The rest of this template is really a base.
Text::Xslate has a great functionality for this, cascading. So what I will do is I will rename this register.tx file to _base.tx, and then I will make a new register.tx file.
symkat@devel:~/HouseNewsWire/Web$ mv root/register.tx root/_base.tx
symkat@devel:~/HouseNewsWire/Web$ vim root/register.tx
%% cascade '_base.tx'
Still everything is exactly the same, but I'm starting to restructure things. I follow the rule that files to be included by other files should start with an underscore and a good name. Files intended to be composed of included and cascaded templates should be named after the page they are associated with.
I want all of the templates that use _base.tx to be able to put content right under the nav bar. So I will add a block to _base.tx
</head>
<body>
<div class="container">
[% include '_navbar.tx' %]
%% block content -> {
<h4>This area replaced by overriding content</h4>
%% }
</div>
Now if I refresh the registration page I will see the following:
That content can be replaced by using the override keyword in my register.tx template.
%% cascade '_base.tx'
%% override content -> {
<p>This is the registration page.</p>
%% }
Now when I refresh the page, I see the content I added to register.tx
That was a detour from building my registration form. But this will let me build the next things even faster now that my templates are more sorted out.
I need to ask the user for four bits of information. I want their email address, it will identify their user account. I want a display name to sign posts from. I want their password, and for kicks and giggles I want them to type their password again.
I will make a generic form that posts to /register
%% cascade '_base.tx'
%% override content -> {
<div class="row my-4">
<div class="col">
</div>
<div class="col">
<form method="POST" action="/register">
</form>
</div>
</div>
%% }
For each bit of information I want from the user, I will need to make an input box. I don't like how cluttered the information I don't want to see gets, so I will make a new template to include.
symkat@devel:~/HouseNewsWire/Web$ mkdir root/_forms/
symkat@devel:~/HouseNewsWire/Web$ vim root/_forms/input.tx
<!-- RUN _forms/input.tx -->
<div class="mb-3">
<label for="form_[% $name %]" class="form-label">[% $title %]</label>
<input type="[% $type %]" class="form-control" id="form_[% $name %]" name="[% $name %]" aria-describedby="form_[% $name %]_help">
<div id="form_[% $name %]_help" class="form-text">[% $help %]</div>
</div>
<!-- END _forms/input.tx -->
This gives me all of the boiler plate from Bootstrap's recommendation, but I only have to include a template and add a bit of data, invoking a template while giving it specific variables works like this:
%% include '_forms/input.tx' { type => 'email', name => 'email',
%% title => 'Email address',
%% help => 'You will never be emailed, just use a unique email for your login.',
%% };
With that in my register.tx I will include the form/input.tx and fill in each $var with what I pass it. Now I can build form entry in this style quickly and clearly with a bunch of copy-paste.
I add a button to register, and my full register.tx now looks like this:
%% cascade '_base.tx'
%% override content -> {
<div class="row my-4">
<div class="col">
</div>
<div class="col">
<form method="POST" action="/register">
%% include '_forms/input.tx' { type => 'email', name => 'email',
%% title => 'Email address',
%% help => 'You will never be emailed, just use a unique email for your login.',
%% };
%% include '_forms/input.tx' { type => 'text', name => 'name',
%% title => 'Display name',
%% help => 'Your posts will show this name.',
%% };
%% include '_forms/input.tx' { type => 'password', name => 'password',
%% title => 'Password',
%% help => '',
%% };
%% include '_forms/input.tx' { type => 'password', name => 'confirm',
%% title => 'Confirm Password',
%% help => '',
%% };
<button type="submit" class="btn btn-primary float-end">Register</button>
</form>
</div>
</div>
%% }
Now I need to take a look at the website, and see if it's showing up right.
It looks like it's working. I fill in the information I'm prompted for and click register... the page refreshes and looks the same, but now the form fields are blank.
When I look on the backend log, I see cool things happening.
[info] *** Request 8 (0.003/s) [21479] [Mon Apr 19 19:05:03 2021] ***
[debug] Path is "/register"
[debug] "POST" request for "register" from "172.16.32.56"
[debug] Body Parameters are:
.-------------------------------------+--------------------------------------.
| Parameter | Value |
+-------------------------------------+--------------------------------------+
| confirm | correct horse battery staple |
| email | symkat@symkat.com |
| name | Kate |
| password | correct horse battery staple |
'-------------------------------------+--------------------------------------'
[debug] Response Code: 200; Content-Type: text/html; charset=utf-8; Content-Length: unknown
[info] Request took 0.014585s (68.564/s)
.------------------------------------------------------------+-----------.
| Action | Time |
+------------------------------------------------------------+-----------+
| /base | 0.000158s |
| /register | 0.000052s |
| /end | 0.009191s |
| -> HouseNewsWire::Web::View::Xslate->process | 0.008707s |
'------------------------------------------------------------+-----------'
I see the request came in. It has some body parameters with it. My email, name, password, and confirmation show! It followed the same path of going through base, register, and end before being processed by the template. It does show the request is a POST request instead of a GET request.
This is very exciting to me. I now have an application up and running, I have templates built, and I'm able to fill out a form and have the information passed back into my application.
Now that I have this data coming in, I need to process it. When I look back at my register controller I see
sub register :Chained('base') PathPart('register') Args(0) {
my ( $self, $c ) = @_;
}
It's not doing anything. Any request for /register goes to it. Once the request comes in, nothing happens. The template that xslate uses is based on the action name here and the namespace. So we're in the root namespace, '' and the action 'register'. So our template is invoked and sent back no problem.
Now I want to seperate GET requests where users are expecting to see information, and POST requests, where users are expecting their request to result in a change. I want this seperate in the code itself, too.
So I am going to change this into two routes, one which will only be called when the request is a GET request, it'll be just like what we have now. Then a second controller that will be called when the request is a POST request, and data is being sent to it.
sub get_register :Chained('base') PathPart('register') Args(0) Method('GET') {
my ( $self, $c ) = @_;
}
sub post_register :Chained('base') PathPart('register') Args(0) Method('POST') {
my ( $self, $c ) = @_;
}
Now I will run it again, however I get an error.
When I changed the names of the methods, the action changed. Under the hood, the template used can be overriden by calling $c->stash->{template} and giving it the name directly. I'll do that here.
sub get_register :Chained('base') PathPart('register') Args(0) Method('GET') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'register.tx';
}
sub post_register :Chained('base') PathPart('register') Args(0) Method('POST') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'register.tx';
}
Now that I've done this, my registration page renders again. There are a couple of things I'll want to address here while I'm processing the form. I need to do the following:
The first thing I'm going to do is make sure I know how to access those values. So I change the post_register controller to get this information and display it in the web browser.
sub post_register :Chained('base') PathPart('register') Args(0) Method('POST') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'register.tx';
my $email = $c->req->body_data->{email};
my $name = $c->req->body_data->{name};
my $password = $c->req->body_data->{password};
my $confirm = $c->req->body_data->{confirm};
$c->res->body( "Email: $email <br /> Name: $name <br /> Password: $password / $confirm" );
}
Now when I fill out the form I get a page with just that information.
I need to extend this to include error checking. I should make sure all of those values are defined, and I should additionally make sure the password and confirmation matches. If I have any errors, I will add them to an array in my stash. If that array exists, I'll stop processing the form. If I get past that, I will mark created as true.
sub post_register :Chained('base') PathPart('register') Args(0) Method('POST') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'register.tx';
my $email = $c->req->body_data->{email};
my $name = $c->req->body_data->{name};
my $password = $c->req->body_data->{password};
my $confirm = $c->req->body_data->{confirm};
# Make sure all fields are filled out.
if ( ! ( $email && $name && $password && $confirm ) ) {
push @{$c->stash->{errors}}, "All fields are required.";
}
# Make sure the password/confirm matches.
if ( $password ne $confirm ) {
push @{$c->stash->{errors}}, "Password and confirmation do not match.";
}
$c->detach if exists $c->stash->{errors};
$c->stash->{created} = 1;
}
Before I continue I will want to make changes to the register.tx template. I should show these errors, so I need to extend the root/register.tx template. I'll put an error or success section to the left of the register form. If these values don't exist (likely for all GET requests) nothing will display in that section.
%% override content -> {
<div class="row my-4">
<div class="col">
%% if ( $errors ) {
<h5>There were errors with your request:</h5>
<ul>
%% for $errors -> $error {
<li class="text-danger">[% $error %]</li>
</ul>
%% }
%% }
%% if ( $created ) {
<h5>Congrats! An account would be created!</h5>
%% }
</div>
<div class="col">
<form method="POST" action="/register">
Now once I've resubmitted the form I get the message that an account would be created.
When I enter passwords that don't match, I get an error.
One more thing I notice is that if I get an error all of my values are gone. Someone would have to enter their email and display name again. I could make sure to fill this information back in for them.
I'll edit root/_forms/input.tx to inclue a value="[% $value %]" attribute.
<!-- RUN _forms/input.tx -->
<div class="mb-3">
<label for="form_[% $name %]" class="form-label">[% $title %]</label>
<input type="[% $type %]" class="form-control" id="form_[% $name %]" name="[% $name %]" value="[% $value %]" aria-describedby="form_[% $name %]_help">
<div id="form_[% $name %]_help" class="form-text">[% $help %]</div>
</div>
<!-- END _forms/input.tx -->
Now I edit register.tx itself so that my forms for email and name now look include the value declaration.
</div>
<div class="col">
<form method="POST" action="/register">
%% include '_forms/input.tx' { type => 'email', name => 'email',
%% title => 'Email address',
%% help => 'You will never be emailed, just use a unique email for your login.',
%% value => $form_email
%% };
%% include '_forms/input.tx' { type => 'text', name => 'name',
%% title => 'Display name',
%% help => 'Your posts will show this name.',
%% value => $form_name
%% };
Finally, I have to make one edit to lib/HouseNewsWire/Web/Controller/Root.pm, I need to set form_email and form_name in the post handler, so that the template will display with them.
sub post_register :Chained('base') PathPart('register') Args(0) Method('POST') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'register.tx';
my $email = $c->req->body_data->{email};
my $name = $c->req->body_data->{name};
my $password = $c->req->body_data->{password};
my $confirm = $c->req->body_data->{confirm};
$c->stash->{form_email} = $email;
$c->stash->{form_name} = $name;
# Make sure all fields are filled out.
if ( ! ( $email && $name && $password && $confirm ) ) {
push @{$c->stash->{errors}}, "All fields are required.";
}
Now when I submit a form without all of the fields filled out, like entering the password for just one of them, I get errors from the backend.
There was a lot to do, but all of this will make the next features easier. I now have my display layer working, good templating, and I have controllers that are working. There is still one major piece missing -- the database.
Before I can store a user account, I need to have somewhere to store it. So now I've got to take a detour from the application itself and start to create the database model.
Now I need to write the initial things to get the database working. I'll start with creating a new top-level directory for the database and begining work on a schema file there.
symkat@devel:~/HouseNewsWire/Web$ cd ..
symkat@devel:~/HouseNewsWire$ mkdir -p Database/etc
symkat@devel:~/HouseNewsWire$ cd Database/
symkat@devel:~/HouseNewsWire/Database$ vim etc/schema.sql
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE person (
id serial PRIMARY KEY,
name text not null,
email citext not null unique,
is_enabled boolean not null default true,
created_at timestamptz not null default current_timestamp
);
CREATE TABLE auth_password (
person_id int not null unique references person(id),
password text not null,
salt text not null,
updated_at timestamptz not null default current_timestamp,
created_at timestamptz not null default current_timestamp
);
This is the SQL file I start with. The citext extention gives me case-insensitive matching, which I use for the email address. I make sure that email is unique. Each of these fields, I want something in, so all of them are marked as not null. created_at columns will automatically populate with when the record was created.
I like to seperate user information from authentication mechanizms. So the user information, person, has no password. Instead, I have a table auth_password which points to a user account. I never want an auth_password record to map to more than one user, so I make the person_id unique. Splitting authentication like this now means if I wanted to add an alternative authentication system in the future, it will be much easier.
Now I have my schema.sql file written, I will want to generate the DBIx::Class schema libraries, and for that I'll use SchemaBuilder to make it quick.
symkat@devel:~/HouseNewsWire/Database$ mkdir bin
symkat@devel:~/HouseNewsWire/Database$ curl -Lo bin/create-classes \
https://raw.githubusercontent.com/symkat/SchemaBuilder/master/create-classes
symkat@devel:~/HouseNewsWire/Database$ chmod u+x bin/create-classes
symkat@devel:~/HouseNewsWire/Database$ ./bin/create-classes HouseNewsWire::DB
Unable to find image 'postgres:11' locally
11: Pulling from library/postgres
62deabe7a6db: Pulling fs layer
24c7b6331486: Pulling fs layer
...
Dumping manual schema for HouseNewsWire::DB to directory /app/lib ...
Schema dump completed.
5f4db710f8c1eae597aea3097faf700e3214a56b333e821a765a1ee007bb10eb
symkat@devel:~/HouseNewsWire/Database$ tree
.
├── bin
│ └── create-classes
├── etc
│ └── schema.sql
└── lib
└── HouseNewsWire
├── DB
│ └── Result
│ ├── AuthPassword.pm
│ └── Person.pm
└── DB.pm
6 directories, 5 files
symkat@devel:~/HouseNewsWire/Database$
Just like that I have my classes generated. I don't have a database, though. I really need one of those, and I want it to be really easy to load up the schema, test it out, and then destroy and recreate it. Docker will make this easier.
I am going to put all of the docker files into .docker, and I'm going to call this one .docker/database.yml.
symkat@devel:~/HouseNewsWire/Database$ cd ..
symkat@devel:~/HouseNewsWire$ mkdir .docker
symkat@devel:~/HouseNewsWire$ vim .docker/database.yml
version: '3'
services:
database:
image: postgres:11
container_name: housenewswire-database
ports:
- 127.0.0.1:5432:5432
environment:
- POSTGRES_PASSWORD=housenewswire
- POSTGRES_USER=housenewswire
- POSTGRES_DB=housenewswire
volumes:
- ./etc/schema.sql:/docker-entrypoint-initdb.d/000_schema.sql:ro
- database:/var/lib/postgresql/data
volumes:
database:
This docker container is using the postgres docker container, and loading my ./etc/schema.sql file into the entry-point directory. The environment variables here are setting up a user account and database. The schema is loaded into that.
While the database is running, it will be using a volume for data. This means I can start and stop the database and the data will persist. If I want to load a new database schema, I can simply delete the volume. With the ports declaration, the postgresql database will be listening on 127.0.0.1:5432 and not exposed directly to the Internet.
Now if I want to start the database, I can use docker-compose.
symkat@devel:~/HouseNewsWire$ docker-compose --project-directory ./Database -f ./.docker/database.yml up
Recreating housenewswire-database ... done
Attaching to housenewswire-database
Now I have a postgresql database running locally with my schema in it. I can go back to the webapp and begin to use the database for account creation and user login.
I have built HouseNewsWire::DB in ../Database/lib, and I have a running PostgreSQL server. I need to make my webapp aware of HouseNewsWire::DB and to configure it as a model, so that I can use it from within catalyst controllers.
Right now, HouseNewsWire::DB isn't found.
symkat@devel:~/HouseNewsWire/Web$ plx -MHouseNewsWire::DB -e1
Can't locate HouseNewsWire/DB.pm in @INC (you may need to install the HouseNewsWire::DB module) (@INC contains:
To use HouseNewsWire::DB in development, I'll add it to the plx libspec so that it can be found. I will also need to install the modules it depends on.
symkat@devel:~/HouseNewsWire/Web$ plx --config libspec add 40HNW-DB.dir ../Database/lib
symkat@devel:~/HouseNewsWire/Web$ plx --cpanm -llocal DBIx::Class::Schema::Config \
DBIx::Class::InflateColumn::Serializer \
DBIx::Class::DeploymentHandler \
MooseX::AttributeShortcuts \
DBD::Pg
symkat@devel:~/HouseNewsWire/Web$ plx -MHouseNewsWire::DB -e1
symkat@devel:~/HouseNewsWire/Web$
Now plx can see HouseNewsWire::DB and I can begin to set it up in Catalyst.
To create the model, I'll need to make a new directory where the models go and then add my model.
symkat@devel:~/HouseNewsWire/Web$ mkdir lib/HouseNewsWire/Web/Model
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web/Model/DB.pm
package HouseNewsWire::Web::Model::DB;
use Moose;
use namespace::autoclean;
extends 'Catalyst::Model::DBIC::Schema';
__PACKAGE__->config(
schema_class => 'HouseNewsWire::DB',
connect_info => [ 'HNW_DB' ],
);
__PACKAGE__->meta->make_immutable;
This model will let me use HouseNewsWire::DB just by accessing $c->model('DB') in any controller. I need to add a configuration file with the database credentials, so I also create the file dbic.yaml.
symkat@devel:~/HouseNewsWire/Web$ vim dbic.yaml
HNW_DB:
dsn: dbi:Pg:host=localhost;dbname=housenewswire
user: housenewswire
password: housenewswire
If I restart my app now, on the loading screen I will see the new Model.
[debug] Loaded engine "Catalyst::Engine"
[debug] Found home "/home/symkat/HouseNewsWire/Web"
[debug] Loaded components:
.-----------------------------------------------------------------+----------.
| Class | Type |
+-----------------------------------------------------------------+----------+
| HouseNewsWire::Web::Controller::Root | instance |
| HouseNewsWire::Web::Model::DB | instance |
| HouseNewsWire::Web::Model::DB::AuthPassword | class |
| HouseNewsWire::Web::Model::DB::Person | class |
| HouseNewsWire::Web::View::Xslate | instance |
'-----------------------------------------------------------------+----------'
With all of this in place, it's time to update the controller so that a user can register their account.
Now that everything is up and running, there is still one thing I need to change in the HouseNewsWire::DB. I have an auth_password table. When a user has a record in this table, that record will be used to verify if they can login. I don't want to store passwords in plain text, and I don't want to have my controller managing hashing passwords. So I will need to extend the model for HouseNewsWire::DB::Result::AuthPassword to have functions to set, update, and check the user's password. I am going to use a couple of modules to handle doing the hashing itself, so I'll install those now as well.
symkat@devel:~/HouseNewsWire/Web$ plx --cpanm -llocal Archive::Zip
symkat@devel:~/HouseNewsWire/Web$ plx --cpanm -llocal Crypt::Eksblowfish::Bcrypt Crypt::Random
symkat@devel:~/HouseNewsWire/Web$ vim ../Database/lib/HouseNewsWire/DB/Result/AuthPassword.pm
At the bottom of the file is the line # You can replace this text with custom code...`, that line I will begin this code at.
# You can replace this text with custom code or comments, and it will be preserved on regeneration
__PACKAGE__->set_primary_key('person_id');
use Crypt::Eksblowfish::Bcrypt qw( bcrypt_hash en_base64 de_base64 );
use Crypt::Random;
sub check_password {
my ( $self, $password ) = @_;
return de_base64($self->password) eq bcrypt_hash({
key_nul => 1,
cost => 8,
salt => de_base64($self->salt),
}, $password );
}
sub set_password {
my ( $self, $password ) = @_;
$self->_fill_password( $password );
$self->insert;
return $self;
}
sub update_password {
my ( $self, $password ) = @_;
$self->_fill_password( $password );
$self->update;
return $self;
}
sub _fill_password {
my ( $self, $password ) = @_;
my $salt = random_salt();
$self->password(
en_base64(
bcrypt_hash({
key_nul => 1,
cost => 8,
salt => $salt,
}, $password )
)
);
$self->salt( en_base64($salt) );
}
sub random_salt {
Crypt::Random::makerandom_octet( Length => 16 );
}
1;
This code does a couple of things. The function _fill_password does the work of creating a hash of the password, and storing that along with a random per-user salt. It does not insert it into the database, or update the database in any way. Next, the function set_password is used the first time a password is set. This function will create the password record. After the record exists, update_password will be used to change it. Finally, to verify a password is correct the function check_password is written.
With these changes in place, I can write my user registration code in the controller.
First, I'll want to include Try::Tiny, so at the top of HouseNewsWire::Web::Controller::Root I add that library. Then I extend the post_register controller.
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web/Controller/Root.pm
package HouseNewsWire::Web::Controller::Root;
use Moose;
use namespace::autoclean;
use Try::Tiny;
BEGIN { extends 'Catalyst::Controller' }
Now Try::Tiny will be loaded.
sub post_register :Chained('base') PathPart('register') Args(0) Method('POST') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'register.tx';
...
# Bail out if there are any errors.
$c->detach if exists $c->stash->{errors};
# Create the DB entry for the user and set their password.
my $person = try {
$c->model('DB')->schema->txn_do(sub {
my $person = $c->model('DB')->resultset('Person')->create({
email => $email,
name => $name,
});
$person->new_related( 'auth_password', {} )->set_password( $password );
return $person;
});
} catch {
# If there was an error creating the user, report it and then bail out.
push @{$c->stash->{errors}}, "Account could not be created: $_";
$c->detach;
};
$c->stash->{created} = 1;
}
The try { } catch {} pattern used here means the following things are going to happen:
Because my catch includes $c->detach it means the rest of the code will not execute.
I begin a transaction to store the user account. This means that if setting the password fails, the user account will not be commited. All of the database changes inside of my txn_do will be rolled back if this block of code fails. Its important to note that other stateful changes will not be rolled back, so if I changed a file or made an API call inside the transaction, DBIx::Class has no idea how to handle that, and I should do it myself in a catch {} block.
I repeat the my person inside of the try block. It is the best name for it, and I am in another scope. I create the database record for the user. Once I have that, I can create a related entry, so I use new_created because I need to call set_password. My password fields were defined as not null so if I simply used create_related I would get an error. So I use new_related to get the record, and then set the password. The record is then inserted into the database.
Once both of these records are created and inserted, I return the newly $person object.
Now I'll reload the app and test out registering an account.
It looks like I don't have any errors. I want to check the database, so I open another terminal. I can connect using psql and get a command line interface to the database and look at the tables. I'm going to see what the records look like now.
symkat@devel:~$ psql -U housenewswire housenewswire -h localhost
Password for user housenewswire:
psql (11.11 (Debian 11.11-0+deb10u1))
Type "help" for help.
housenewswire=# \dt
List of relations
Schema | Name | Type | Owner
--------+---------------+-------+---------------
public | auth_password | table | housenewswire
public | person | table | housenewswire
(2 rows)
housenewswire=# select * from person; select * from auth_password;
id | name | email | is_enabled | created_at
----+------+-------------------+------------+-------------------------------
1 | Kate | symkat@symkat.com | t | 2021-04-22 20:10:04.746306+00
(1 row)
person_id | password | salt | updated_at | created_at
-----------+---------------------------------+------------------------+-------------------------------+-------------------------------
1 | .XiCt43.YSbGqB0IbZuThiWgYBP3Xjy | IccpO1g4rj.HZCxqFGHD7O | 2021-04-22 20:10:04.746306+00 | 2021-04-22 20:10:04.746306+00
(1 row)
housenewswire=#
Look at that, the account shows up in the database! I wonder what would happen if I tried to register the same account twice.
My database looks exactly the same, so the constraints are working well. I should add nicer errors, but I will save that for a later improvement. User registration is done. Someone can come to the website, sign up for an account, submit a password, and have their account created and password securely stored.
This feature is complete. Before I move on, I should commit the state of everything in git.
symkat@devel:~/HouseNewsWire$ git add .docker Database/ Web/lib Web/root Web/app.psgi Web/cpanfile Web/dbic.yaml
symkat@devel:~/HouseNewsWire$ git commit -m "Initial Commit"
Now I need to move my attention to letting users login.
The user registration required a lot of work to set up because I had to built a lot of the tools as I went. Now it should be easier to build the user login.
I will start by coping the registration.tx file to login.tx and editing it to be a login page.
symkat@devel:~/HouseNewsWire/Web$ cp root/register.tx root/login.tx
symkat@devel:~/HouseNewsWire/Web$ vim root/login.tx
%% cascade '_base.tx'
%% override content -> {
<div class="row my-4">
<div class="col">
%% if ( $errors ) {
<h5>There were errors with your request:</h5>
<ul>
%% for $errors -> $error {
<li class="text-danger">[% $error %]</li>
%% }
</ul>
%% }
%% if ( $user ) {
<h5>Congrats! You have logged in as [% $user.name %] via [% $user.email %]</h5>
%% }
</div>
<div class="col">
<form method="POST" action="/login">
%% include '_forms/input.tx' { type => 'email', name => 'email',
%% title => 'Email address',
%% help => 'The email account you registered with.',
%% value => $form_email
%% };
%% include '_forms/input.tx' { type => 'password', name => 'password',
%% title => 'Password',
%% help => '',
%% };
<button type="submit" class="btn btn-primary float-end">Login</button>
</form>
</div>
</div>
%% }
My login form supports the same type of errors as register.tx. I am going to set the $c->stash->{user} to the user object once the user has logged in, so I will be able to see the name and email address when I try to login and that will confirm it is pulling it from the database. I use basically the same form, however I am posting to /login instead of /register, and only an email and password are being submitted.
Now I've got to add the controllers for login. I'll follow the same pattern with get_login and post_login and create these controllers now.
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web/Controller/Root.pm
sub get_login :Chained('base') PathPart('login') Args(0) Method('GET') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'login.tx';
}
sub post_login :Chained('base') PathPart('login') Args(0) Method('POST') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'login.tx';
# Get the email and password values.
my $email = $c->req->body_data->{email};
my $password = $c->req->body_data->{password};
# Try to load the user account, otherwise add an error.
my $person = $c->model('DB')->resultset('Person')->find( { email => $email } )
or push @{$c->stash->{errors}}, "Invalid email address or password.";
# Do not continue if there are errors.
$c->detach if $c->stash->{errors};
# Check the password supplied matches, otherwise add an error.
$person->auth_password->check_password( $password )
or push @{$c->stash->{errors}}, "Invalid email address or password.";
# Do not continue if there are errors.
$c->detach if $c->stash->{errors};
# Store the user object in the stash.
$c->stash->{user} = $person;
}
Once I restart the app and try to login, I see the following.
When I use an invalid email or password, I get an error.
So far, so good. What I don't have is any kind of session management. After I login, if I go to any other page, the website no longer knows I am logged in.
To make the website know I am logged in, I need the login form to give me a cookie afterwards that can be used to find the right $user and load that.
I need to edit Web.pm to add sessions to it. I'll use the Session Plugin with cookie storage, and then store the user id that the person belogs to in a cookie on their machine.
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web.pm
package HouseNewsWire::Web;
...
use Catalyst::Runtime 5.80;
use Catalyst qw| -Debug Static::Simple Session Session::Store::Cookie Session::State::Cookie|;
...
# Disable deprecated behavior needed by old applications
disable_component_resolution_regex_fallback => 1,
);
__PACKAGE__->config(
'Plugin::Session' => {
cookie_name => 'session_id',
storage_cookie_name => 'session_data',
storage_cookie_expires => '+30d',
storage_secret_key => 'SuperSecretLongKeyThatShouldBeRandom',
},
);
__PACKAGE__->config(
...
I've added the Session plugins required, and then configured Plugin::Session. This will allow me to store and access information in $c->session as a hashref.
So I am going to edit Root.pm to store the user id in the session.
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Controller/Root.pm
# Check the password supplied matches, otherwise add an error.
$person->auth_password->check_password( $password )
or push @{$c->stash->{errors}}, "Invalid email address or password.";
# Do not continue if there are errors.
$c->detach if $c->stash->{errors};
# Store the user id in the session.
$c->session->{uid} = $person->id;
}
Now that I have made that edit, I want to make sure that if the user has a session with a user id in it, I load their user information and make it available. This is where the chained dispatching really starts to shine, I'm going to modify the base chain.
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Controller/Root.pm
sub base :Chained('/') PathPart('') CaptureArgs(0) {
my ( $self, $c ) = @_;
# Check if we have a session.
if ( exists $c->session->{uid} ) {
# Find the user account for the session.
my $person = $c->model('DB')->resultset('Person')->find( $c->session->{uid} );
# If the user account exists and is enabled, then store the user in the stash.
if ( $person && $person->is_enabled ) {
$c->stash->{user} = $person;
}
}
}
With this change, every request will check for this session cookie and load up the user account so it is available through the rest of the request cycle.
Now after I have logged in, if I visit the login page again, it gives me the same congratulations message. I have the ability to register an account, and to login to it. Once I login, cookies are set so that future requests have my account information accessable to the backend.
I am ready to start posting and displaying messages now.
I need to update the database if I'm going to add messages. The first place I'll start is with extending the schema.
symkat@devel:~/HouseNewsWire/Web$ cd ../Database/
symkat@devel:~/HouseNewsWire/Database$ vim etc/schema.sql
salt text not null,
updated_at timestamptz not null default current_timestamp,
created_at timestamptz not null default current_timestamp
);
CREATE TABLE message (
id serial PRIMARY KEY,
author_id int not null references person(id),
content text not null,
created_at timestamptz not null default current_timestamp
);
CREATE TABLE message_read (
message_id int not null references message(id),
person_id int not null references person(id),
is_read boolean not null default true,
created_at timestamptz not null default current_timestamp,
PRIMARY KEY (message_id, person_id)
);
I've added two tables messages, and messages_read. The first table will hold a message, which will have content, a time it was created, and an author who created it. The second table, message_read will be used as a per-user filter to stop displaying messages they have marked as read, and will be used in the next feature I develope.
With these updates in place I will update the classes. The custom code I added for user authentication should all be perserved because I put it in the section of the file that can be edited and still regenerated.
symkat@devel:~/HouseNewsWire/Database$ ./bin/create-classes HouseNewsWire::DB
DBI connect('host=psqldb;dbname=dbic','dbic',...) failed: could not connect to server: Connection refused
Is the server running on host "psqldb" (172.17.0.2) and accepting
TCP/IP connections on port 5432? at /bin/build-schema line 8.
Connection failed, waiting 2 seconds before trying to connect.
Dumping manual schema for HouseNewsWire::DB to directory /app/lib ...
Schema dump completed.
b1030a3d984ca183744d50b271f5d16019ccedd82fcef52d9d016bcd0de1a123
symkat@devel:~/HouseNewsWire/Database$ tree
.
├── bin
│ └── create-classes
├── dist.ini
├── etc
│ └── schema.sql
└── lib
└── HouseNewsWire
├── DB
│ └── Result
│ ├── AuthPassword.pm
│ ├── Message.pm
│ ├── MessageRead.pm
│ └── Person.pm
└── DB.pm
6 directories, 8 files
I also want to delete my database and start over. I use control c (^C) on the window that was running the docker container, and then delete the volume and recreate it. This will give me a blank database again.
symkat@devel:~/HouseNewsWire$ docker-compose --project-directory ./Database -f ./.docker/database.yml down -v
symkat@devel:~/HouseNewsWire$ docker-compose --project-directory ./Database -f ./.docker/database.yml up
Now I have the database classes extended and a blank devel database running. I need to add the templates and the controllers.
symkat@devel:~/HouseNewsWire/Database$ cd ../Web
symkat@devel:~/HouseNewsWire/Web$ vim root/dashboard.tx
%% cascade '_base.tx'
%% around content -> {
<div class="row my-4">
<form method="POST" action="/dashboard">
<div class="mb-3">
<label for="message" class="form-label">Post a Message</label>
<textarea class="form-control" name="message" id="message" rows="3"></textarea>
</div>
<button type="submit" class="btn btn-primary float-end">Post Message</button>
</form>
</div>
<hr />
%% }
I've just inlined this instead of adding a _forms/textarea. With this form I should get a message posted at /dashboard, so I start working on that controller.
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web/Controller/Root.pm
sub get_dashboard :Chained('base') PathPart('dashboard') Args(0) Method('GET') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'dashboard.tx';
# Is there a valid user logged in? - If not, send them to the login page.
if ( ! $c->stash->{user} ) {
$c->res->redirect( $c->uri_for_action('/get_login') );
$c->detach;
}
}
sub post_dashboard :Chained('base') PathPart('dashboard') Args(0) Method('POST') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'dashboard.tx';
# Is there a valid user logged in? - If not, send them to the login page.
if ( ! $c->stash->{user} ) {
$c->res->redirect( $c->uri_for_action('/get_login') );
$c->detach;
}
# Get the message contents.
my $message = $c->req->body_data->{message};
# Store the message in the database.
$c->stash->{user}->create_related( 'messages', {
content => $message,
});
}
I add these two controllers to Root.pm. I know that if I don't have a user object in the stash that this isn't somebody who has logged in. If they aren't logged in, I don't want them to be in the dashboard. So I redirect them to the login page.
In the POST controller, I get the message from the form and I put it into the database as a message the user has created. Now I will try to access the dashboard, but I'm redirected to the login page. Remember, I reset the database so I actually need to go and make a new account.
It would be nice if after registering the account, I was just redirected to the dashboard. I can now do that quick a quick edit to the last line, instwad of $c->stash->{created}, I'll just redirect to the dashboard.
sub post_register :Chained('base') PathPart('register') Args(0) Method('POST') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'register.tx';
...
} catch {
# If there was an error creating the user, report it and then bail out.
push @{$c->stash->{errors}}, "Account could not be created: $_";
$c->detach;
};
# Send the user to the dashboard once they have made an account.
$c->res->redirect( $c->uri_for_action( '/get_dashboard' ) );
}
Once I restart the app and register an account, I am greeted by the dashboard!
The page refreshes when I post the message and I don't see any messages. If I reconnect to the psql client, I can see the message I just created.
symkat@devel:~$ psql -U housenewswire housenewswire -h localhost
Password for user housenewswire:
psql (11.11 (Debian 11.11-0+deb10u1))
Type "help" for help.
housenewswire=# select * from message;
id | author_id | content | created_at
----+-----------+--------------+-------------------------------
1 | 1 | Hello World! | 2021-04-24 07:21:49.282882+00
(1 row)
housenewswire=#
Now I need to pass these messages to the template so that they can be displayed. I'll modify the controllers a bit.
sub get_dashboard :Chained('base') PathPart('dashboard') Args(0) Method('GET') {
...
# Push the 100 latest posts into the stash.
push @{$c->stash->{messages}}, $c->model('DB')->resultset('Message')->search(
{},
{ order_by => { -desc => 'me.created_at' }, rows => 100 }
)->all;
}
...
sub post_dashboard :Chained('base') PathPart('dashboard') Args(0) Method('POST') {
...
$c->res->redirect( $c->uri_for_action( '/get_dashboard' ) );
}
The change in get_dashboard will display messages posted. The change in post_dashboard will redirect the user back to the get controller after they post the message. This will result in a refresh for the user and then they will see their post.
I need to update the template so that it can display these messages.
</div>
<button type="submit" class="btn btn-primary float-end">Post Message</button>
</form>
</div>
<hr />
%% for $messages -> $message {
<div class="card my-4">
<div class="card-header">[% $message.author.name %]</div>
<div class="card-body">
<blockquote class="blockquote mb-0">
<p>[% $message.content %]</p>
<footer class="blockquote-footer initialism float-end">
[% $message.created_at.strftime( "%a %d %b @ %H:%M:%S" ) %]
</footer>
</blockquote>
</div>
</div>
%% }
%% }
Now when I load the dashboard, I see my message!
If I post another message, it refreshes and automatically shows up!
This makes a good time for my second commit.
symkat@devel:~/HouseNewsWire$ git log
commit 1f3a31f89241e70146df58ec50fd8d5d2de29c9a (HEAD -> master)
Author: Kaitlyn Parkhurst
Date: Sat Apr 24 07:44:50 2021 +0000
First Features
Registered users can login, post and read messages.
My work is now saved at this point. The broad strokes of how I'd like the app to work is now working. People can register accounts, login, post and read messages. At this point, I have a working application.
I want to be able to mark messages as read and have them not show up anymore. So I'll need to add a button to each message on the dashboard. I'll update the HTML there.
%% for $messages -> $message {
<div class="card my-4">
<div class="card-header">
[% $message.author.name %]
<span class="float-end">
<form method="post" action="/dashboard/[% $message.id %]">
<button type="submit">X</button>
</form>
</span>
</div>
With this form in place, I will get a POST to /dashboard/
Now I'll add a controller for this to handle the post. I will chain it off of base as I have been doing, but I will expect it to have one argument, the message id.
sub post_dashboard_message :Chained('base') PathPart('dashboard') Args(1) Method('POST') {
my ( $self, $c, $message_id ) = @_;
# Is there a valid user logged in? - If not, send them to the login page.
if ( ! $c->stash->{user} ) {
$c->res->redirect( $c->uri_for_action('/get_login') );
$c->detach;
}
$c->stash->{user}->create_related('messages_read', {
message_id => $message_id,
});
$c->res->redirect( $c->uri_for_action( '/get_dashboard' ) );
}
Once I have added this code, pressing the X will add a database record. I confirm the record exists.
housenewswire=# select * from message_read ;
message_id | person_id | is_read | created_at
------------+-----------+---------+-------------------------------
1 | 1 | t | 2021-04-24 08:17:54.232374+00
(1 row)
housenewswire=#
Now I have one task left, I need to modify the message search so that read messages are excluded.
sub get_dashboard :Chained('base') PathPart('dashboard') Args(0) Method('GET') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'dashboard.tx';
...
# Push the 100 latest posts into the stash, don't include messages already
# marked as read.
push @{$c->stash->{messages}}, $c->model('DB')->resultset('Message')->search(
{
'messages_read.person_id' => [ undef, $c->stash->{user}->id ],
'messages_read.is_read' => [ undef, 0 ],
},
{ order_by => { -desc => 'me.created_at' }, rows => 100, join => 'messages_read' }
)->all;
}
This will get all of the mesages that the user hasn't marked as read, ordered with the most recent first.
Now that I have a working app, there are some things I'd like to clean up. In the upper right hand corner the only link is to register. In my mockups I wanted to present login and register links if the user isn't logged in, and I want to present a logout link when they are logged in.
symkat@devel:~/HouseNewsWire/Web$ vim root/_navbar.tx
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
%% if ( defined $user ) {
<a class="navbar-brand" href="/dashboard">House News Wire</a>
%% } else {
<a class="navbar-brand" href="/">House News Wire</a>
%% }
<div class="navbar-nav justify-content-end" id="navbarNav">
<ul class="navbar-nav">
%% if ( defined $user ) {
<li class="nav-item"><a class="nav-link" href="/logout">Logout</a></li>
%% } else {
<li class="nav-item"><a class="nav-link" href="/register">Register</a></li>
<li class="nav-item"><a class="nav-link" href="/login">Login</a></li>
%% }
</ul>
</div>
</div>
</nav>
Now when there is a logged in user, the logo text link points to /dashboard, when there is no logged in user it points to /. When there is a user, they have a logout link. When there is no user, there is a register and login link.
The link to /logout doesn't actually exist, so I should quickly add that to the controller.
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web/Controller/Root.pm
sub get_logout :Chained('base') PathPart('logout') Args(0) Method('GET') {
my ( $self, $c ) = @_;
delete $c->session->{uid};
$c->res->redirect( $c->uri_for_action( '/get_login' ) );
}
Now I have a logout button and the controller seems to be working.
Now when a user clicks the logout link they will have the session cookie deleted, and be redirected to the login page.
I want to take all of the dashboard code and put it into its own class. I'll also create a chain where the user authorization happens.
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web/Controller/Dashboard.pm
package HouseNewsWire::Web::Controller::Dashboard;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller' }
sub base :Chained('/base') PathPart('dashboard') CaptureArgs(0) {
my ( $self, $c ) = @_;
# Is there a valid user logged in? - If not, send them to the login page.
if ( ! $c->stash->{user} ) {
$c->res->redirect( $c->uri_for_action('/get_login') );
$c->detach;
}
}
sub get_dashboard :Chained('base') PathPart('') Args(0) Method('GET') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'dashboard.tx';
# Push the 100 latest posts into the stash, don't include messages already
# marked as read.
push @{$c->stash->{messages}}, $c->model('DB')->resultset('Message')->search(
{
'messages_read.person_id' => [ undef, $c->stash->{user}->id ],
'messages_read.is_read' => [ undef, 0 ],
},
{ order_by => { -desc => 'me.created_at' }, rows => 100, join => 'messages_read' }
)->all;
}
sub post_dashboard_message :Chained('base') PathPart('') Args(1) Method('POST') {
my ( $self, $c, $message_id ) = @_;
$c->stash->{user}->create_related('messages_read', {
message_id => $message_id,
});
$c->res->redirect( $c->uri_for_action( '/get_dashboard' ) );
}
sub post_dashboard :Chained('base') PathPart('') Args(0) Method('POST') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'dashboard.tx';
# Get the message contents.
my $message = $c->req->body_data->{message};
# Store the message in the database.
$c->stash->{user}->create_related( 'messages', {
content => $message,
});
$c->res->redirect( $c->uri_for_action( '/get_dashboard' ) );
}
__PACKAGE__->meta->make_immutable;
I removed these functions from Root.pm and moved them to Dashboard.pm. I created a base function to chain off, and made the starting point for that chain /base where the $c->stash->{user} is loaded. If that is not available, then the user is immediately redirected to the login page. This means the next three controllers, chaining off of this one, can assume that authorization has already been handled.
Once this is done, there are still some tweaks to be made. The controller location is now /dashboard/get_dashboard, so my redirects need to be updated. While I'm at it, users should be logged in once they register and and should be sent to the dashboard once they log in. So I make a couple of tweaks and see the diff:
diff --git a/Web/lib/HouseNewsWire/Web/Controller/Dashboard.pm b/Web/lib/HouseNewsWire/Web/Controller/Dashboard.pm
index 5a2b58b..bbf1519 100644
--- a/Web/lib/HouseNewsWire/Web/Controller/Dashboard.pm
+++ b/Web/lib/HouseNewsWire/Web/Controller/Dashboard.pm
@@ -29,7 +29,7 @@ sub post_dashboard_message :Chained('base') PathPart('') Args(1) Method('POST')
message_id => $message_id,
});
- $c->res->redirect( $c->uri_for_action( '/get_dashboard' ) );
+ $c->res->redirect( $c->uri_for_action( '/dashboard/get_dashboard' ) );
}
sub post_dashboard :Chained('base') PathPart('') Args(0) Method('POST') {
@@ -44,7 +44,7 @@ sub post_dashboard :Chained('base') PathPart('') Args(0) Method('POST') {
content => $message,
});
- $c->res->redirect( $c->uri_for_action( '/get_dashboard' ) );
+ $c->res->redirect( $c->uri_for_action( '/dashboard/get_dashboard' ) );
}
__PACKAGE__->meta->make_immutable;
diff --git a/Web/lib/HouseNewsWire/Web/Controller/Root.pm b/Web/lib/HouseNewsWire/Web/Controller/Root.pm
index 9b69ddc..d82a11c 100644
--- a/Web/lib/HouseNewsWire/Web/Controller/Root.pm
+++ b/Web/lib/HouseNewsWire/Web/Controller/Root.pm
@@ -64,6 +64,9 @@ sub post_login :Chained('base') PathPart('login') Args(0) Method('POST') {
# Store the user id in the session.
$c->session->{uid} = $person->id;
+
+ # Send the user to the dashboard once they have logged in.
+ $c->res->redirect( $c->uri_for_action( '/dashboard/get_dashboard' ) );
}
sub get_register :Chained('base') PathPart('register') Args(0) Method('GET') {
@@ -111,9 +114,12 @@ sub post_register :Chained('base') PathPart('register') Args(0) Method('POST') {
push @{$c->stash->{errors}}, "Account could not be created: $_";
$c->detach;
};
+
+ # Authorize the user into the account they created.
+ $c->session->{uid} = $person->id;
# Send the user to the dashboard once they have made an account.
- $c->res->redirect( $c->uri_for_action( '/get_dashboard' ) );
+ $c->res->redirect( $c->uri_for_action( '/dashboard/get_dashboard' ) );
}
sub end :ActionClass('RenderView') { }
The SQL that I call to get the unread messages for a given user is in a controller. It is considered better form to have this type of code in the model itself, so instead of using the model to run the search, I would instead call unread_messages.
I can codify this search by creating a ResultSet object. ResultSet objects allow me to search over a whole table, or add functions to a whole table. Result objects, what I have modified until now, allow me to add functions over a row.
symkat@devel:~/HouseNewsWire/Web$ vim ../Database/lib/HouseNewsWire/DB/ResultSet/Message.pm
package HouseNewsWire::DB::ResultSet::Message;
use warnings;
use strict;
use base 'DBIx::Class::ResultSet';
sub unread_messages {
my ( $self, $person_id ) = @_;
# Select the first 100 messages that are not marked as read for the
# given user.
return $self->search(
{
'messages_read.person_id' => [ undef, $person_id ],
'messages_read.is_read' => [ undef, 0 ],
},
{
order_by => { -desc => 'me.created_at' },
rows => 100,
join => 'messages_read'
},
)->all;
}
1;
This model will let me call a search over the entire message table. The controller for the dashboard can change to use this now.
symkat@devel:~/HouseNewsWire/Web$ vim lib/HouseNewsWire/Web/Controller/Dashboard.pm
sub get_dashboard :Chained('base') PathPart('') Args(0) Method('GET') {
my ( $self, $c ) = @_;
$c->stash->{template} = 'dashboard.tx';
push @{$c->stash->{messages}},
$c->model('DB')->resultset('Message')->unread_messages($c->stash->{user}->id);
}
This gives me a much cleaner looking controller, and reloading the app shows it's all still working as expected.
Now that I have a fully working application, I want to get to work on dockerizing it. So far I have been using HouseNewsWire::DB by adding the library path, now I need to really handle installing that.
I need to build the library, so I need to install Dist::Zilla.
symkat@devel:~/HouseNewsWire/Database$ cpanm Dist::Zilla
--> Working on Dist::Zilla
...
Successfully installed Dist-Zilla-6.017
115 distributions installed
Once I have Dist::Zilla installed, I can build the HouseNewsWire::DB package.
symkat@devel:~/HouseNewsWire/Database$ dzil build
[DZ] beginning to build HouseNewsWire-DB
[DZ] writing HouseNewsWire-DB in HouseNewsWire-DB-0.001
[DZ] building archive with Archive::Tar; install Archive::Tar::Wrapper 0.15 or newer for improved speed
[DZ] writing archive to HouseNewsWire-DB-0.001.tar.gz
[DZ] built in HouseNewsWire-DB-0.001
symkat@devel:~/HouseNewsWire/Database$
Now I need to install the package. I am going to use opan so that HouseNewsWire::DB can be installed like any other dependency.
symkat@devel:~/HouseNewsWire/Web$ plx --cpanm -ldevel App::opan IO::Socket::SSL
symkat@devel:~/HouseNewsWire/Web$ plx opan init
symkat@devel:~/HouseNewsWire/Web$ plx opan add ../Database/HouseNewsWire-DB-0.001.tar.gz
symkat@devel:~/HouseNewsWire/Web$ plx opan merge
symkat@devel:~/HouseNewsWire/Web$
Once I have done this, I can add HouseNewsWire::DB to the end of my cpanfile.
requires "Starman";
requires "DateTime::Format::Pg";
requires "Try::Tiny";
requires "HouseNewsWire::DB";
Once I have this file saved, I'll install the modules again. This time, you'll notice opan
symkat@devel:~/HouseNewsWire/Web$ plx opan carton install
...
Complete! Modules were installed into /home/symkat/HouseNewsWire/Web/local
Now I can build a docker file.
symkat@devel:~/HouseNewsWire$ vim .docker/housenewswire.dockerfile
FROM debian:stable
RUN apt-get update; \
apt-get install -y cpanminus libpq-dev libssl-dev libz-dev build-essential; \
cpanm App::plx;
RUN useradd -d /app catalyst;
USER catalyst
COPY --chown=catalyst:catalyst Web /app/
WORKDIR /app
CMD plx starman
With this docker file, I'll build the docker container.
symkat@devel:~/HouseNewsWire$ docker build . -t symkat/housenewswire -f .docker/housenewswire.dockerfile
Now that I have built my docker container, I want to set up a docker-compose file that will bring up a database and the webapp itself as well.
symkat@devel:~/HouseNewsWire$ vim .docker/housenewswire.yml
version: '3'
services:
website:
image: symkat/housenewswire:latest
container_name: hnw-web
ports:
- 5000:5000
volumes:
- ./.docker/housenewswire/dbic.yaml:/app/dbic.yaml:ro
database:
image: postgres:11
container_name: hnw-db
ports:
- 127.0.0.1:5432:5432
environment:
- POSTGRES_PASSWORD=housenewswire
- POSTGRES_USER=housenewswire
- POSTGRES_DB=housenewswire
volumes:
- ./Database/etc/schema.sql:/docker-entrypoint-initdb.d/000_schema.sql:ro
- database:/var/lib/postgresql/data
volumes:
database:
I also create the directoy .docker/housenewswire and place a dbic.yaml file with the correct hostname.
HNW_DB:
dsn: dbi:Pg:host=database;dbname=housenewswire
user: housenewswire
password: housenewswire
Finally, I am able to bring up the entire environment in docker.
symkat@devel:~/HouseNewsWire$ docker-compose --project-directory ./ -f .docker/housenewswire.yml up
I want to install dex, a tool I wrote so that commands I use in only in a given directory can just be kept in a file in that directory. I'll install it now.
mkdir ~/bin
curl -Lo ~/bin/dex https://raw.githubusercontent.com/symkat/App-Dex/master/scripts/dex
chmod u+x ~/bin/dex
exit
The ~/bin directory is automatically added to my $PATH when it exists, but only on login. So after I log back in, dex is in my path and can be used. I go back into the HouseNewsWire directory and I create a .dex.yaml file.
symkat@devel:~$ cd HouseNewsWire
symkat@devel:~/HouseNewsWire$ vim .dex.yaml
- name: db
desc: "Control Devel DB Only"
children:
- name: start
desc: "Start devel db on localhost via docker."
shell:
- docker-compose --project-directory ./Database -f ./.docker/database.yml up
- name: stop
desc: "Stop devel db on localhost via docker."
shell:
- docker-compose --project-directory ./Database -f ./.docker/database.yml down
- name: status
desc: "Show status of devel db."
shell:
- docker-compose --project-directory ./Database -f ./.docker/database.yml ps
- name: reset
desc: "Wipe devel db data."
shell:
- docker-compose --project-directory ./Database -f ./.docker/database.yml down -v
- name: docker
desc: "Run Docker Container Environment (Web + DB)"
children:
- name: start
desc: "Start Docker Container Environment"
shell:
- docker-compose --project-directory ./ -f ./.docker/housenewswire.yml up
- name: stop
desc: "Stop Docker Container Environment"
shell:
- docker-compose --project-directory ./ -f ./.docker/housenewswire.yml down
- name: status
desc: "Show status of Docker Container Environment"
shell:
- docker-compose --project-directory ./ -f ./.docker/housenewswire.yml ps
- name: reset
desc: "Wipe data from Docker Container Environment"
shell:
- docker-compose --project-directory ./ -f ./.docker/housenewswire.yml down -v
- name: build
desc: "Build HouseNewsWire container"
shell:
- docker build . -t symkat/housenewswire -f .docker/housenewswire.dockerfile
With this file in place, I don't have to remember these commands in the future, I can just use dex to run them.
symkat@devel:~/HouseNewsWire$ dex
db : Control Devel DB Only
start : Start devel db on localhost via docker.
stop : Stop devel db on localhost via docker.
status : Show status of devel db.
reset : Wipe devel db data.
docker : Run Docker Container Environment (Web + DB)
start : Start Docker Container Environment
stop : Stop Docker Container Environment
status : Show status of Docker Container Environment
reset : Wipe data from Docker Container Environment
build : Build HouseNewsWire container
When I run dex I get a nice menu of the options. I could run commands like dex build to build the HouseNewsWire container, or dex db start to start a development database instance like I did when I started this project.
I hope that this has been useful to you!
The code created from this article is freely available and can be found at HouseNewsWire on GitHub.