Building House News Wire


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.

Design & Planning

Thinking through the functionality that I need, at least the following capabilities will need to be developed.

  1. People need to be able to register accounts.
  2. People need to be able to login.
  3. People need to be able read announcements already posted.
  4. People need to be able to mark announcements as read.
  5. People need to be able to post announcements.

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.

Pages

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.

Getting To The Index

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.

Project Environment

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

Project Dependancies

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.

Project Skeleton

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...

HouseNewsWire - First Run

Well, that's silly. There is no index page. I forgot to write a controller for it!

First Controller

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

HouseNewsWire - Hello World

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.

Building User Registration

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.

  1. Registration page with a form.
  2. Form processing of user-submited values.
  3. Registration page able to display backend errors.
  4. Backend able to store newly registered users.
  5. Backend able to store passwords for users.

Registration Page

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.

HouseNewsWire - Registration Source

Navigation Bar

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>
    ...

HouseNewsWire - Register - Navbar

Breaking Down The Template

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:

House News Wire - Register - Replace Content

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

House News Wire - Register - Replaced Content

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.

Building The Form

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.

House News Wire - Register Form

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.

Processing The Form

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.

House News Wire - Register - Method 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:

  1. Make sure the information is there - is everything filled out?
  2. Make sure that the information is right -- are the password and confirmation the same?
  3. Let the user know about any errors

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.

HouseNewsWire - Register - Form Filled

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.

HouseNewsWire - Register - Account Created

When I enter passwords that don't match, I get an error.

HouseNewWire - Register - Account Create 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.

HouseNewsWire - Register - Errors

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.

Database

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.

Docker For Development Database

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.

Building User Registration Continued

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.

Setting up the DBIC model 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.

Storing The User 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:

  • The `try` block will be executed.
  • If the try block has an error, `catch` will be executed, and `$_` will be set to the error.
  • If the try block was successful, the return value of the block will be assigned to `$person`
  • 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.

    HouseNewsWire - Register - Made 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.

    HouseNewsWire - Register - Dup

    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.

    Building User 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.

    HouseNewsWire - Register - Login

    When I use an invalid email or password, I get an error.

    HouseNewsWire - Register - Login Failed

    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.

    Session Management

    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.

    Building Message Posting

    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
    
    

    Back To The Web

    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!

    HouseNewsWire - Message Post - Display

    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!

    HouseNewsWire - Dashboard - Message

    If I post another message, it refreshes and automatically shows up!

    HouseNewsWire - Dashboard - Message Two

    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.

    Building Mark As Read

    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/ when a user clicks on it. The message box now looks like this.

    HouseNewsWire - User X

    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.

    HouseNewsWire - Marked As Read

    Refactor: NavBar

    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' ) );
    }
    
    

    HouseNewsWire - Logout

    Now I have a logout button and the controller seems to be working.

    HouseNewsWire - Login

    Now when a user clicks the logout link they will have the session cookie deleted, and be redirected to the login page.

    ReFactor: Dashboard

    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') { }
    
    

    Refactor: Models

    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.

    Dockerizing House News Wire

    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
    
    

    Compose File

    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
    
    

    Tooling - Dex For Saving Commands

    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.

    Closing

    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.


    Contact Me