I my last post mojolicious-and-docker I have explained how to build and Docker image and to spin up which holds an Mojo app in this one I want to add show how to:
- Add an data base image to the docker compose file
- Create an network between the and the app
- Create and persists your db
- Start the Docker in the containers in the correct order
So lets start.
In the compose.yaml
add the following lines:
database:
image: mariadb
ports:
- "3306:3306"
This code snippet will the db image.
Now add the following lines at the same level with ports in the compose file.
environment:
MARIADB_ROOT_PASSWORD: ${DB_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_USER: ${DB_USER}
This value are red from the .env
file defined in at the same level with the compose file. The content of that files is similar to this:
MARIADB_ROOT_PASSWORD=
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_NAME=
As side note on local machine I make this entry into '/etc/hosts' to be able to connect to the db from outside the Docker container.
127.0.0.1 database
Because you do not want to lose date mount the db files for persistence using this line:
volumes:
- ./maria-data:/var/lib/mysql
At this point you have an Maria db image that we know it will not lose data but nothing is actually in there. Lets address this by mounting an new volume:
- ./my_app/migrations/000_create_db.sql:/docker-entrypoint-initdb.d/000_create_db.sql
Open an editor and this lines in the 000_create_db.sql
script:
CREATE DATABASE IF NOT EXISTS my_app
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
This will run and create the db when you run docker compose up
.
In order to make sure that everything cheeks out we need to: add health check, create and network and make sure that the app starts after the db:
So to add the health check drop this lines
healthcheck:
test: ["CMD-SHELL", "mariadb -u${DB_USER} -p${DB_PASSWORD} -e 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
Then both services database and web and this line:
networks:
- my-app-net
Then at the bottom of the file define the network:
networks:
my-app-net:
To make sure that the app wont break add this in the web service:
depends_on:
database:
condition: service_healthy
This will ensure that the app will start only after the db.
At this point we can start to think about integrating the db in into our app. First thing is first lets add all the Perl libraries into the cpan file:
requires 'File::Slurper' => '0.014';
requires 'Mojolicious' => '9.39',;
requires 'Dotenv' => '0.002';
requires 'DBI' => ' 1.647';
requires 'DBD::MariaDB' => '1.23';
requires 'Dotenv' => '0.002';
requires 'Try::Tiny' => '0.32';
requires 'Digest' => '1.20';
requires 'Digest::SHA' => '6.04';
requires 'Mojolicious::Plugin::Authentication' => '1.39';
requires 'Digest::Bcrypt' => '1.212';
requires 'DBD::Mock::Session::GenerateFixtures' => '0.11';
Now run carton install locally this will update the the cpanfile.snapshot and take of the dependencies.
Now if we type docker compose build
an new image will be create with all the requires libraries baked in.
Now spin the composition with docker compose up
You should see something similar with this:
✔ Container my_app-database-1 Created 0.0s
✔ Container my_app-web-1 Created
No its time to put some meat and the bones at some Perl code in our app and some tables we want to use:
So create this table in the db:
CREATE TABLE IF NOT EXISTS users (
id MEDIUMINT NOT NULL AUTO_INCREMENT,
plugin VARCHAR(30) NOT NULL DEFAULT 'Auth',
username VARCHAR(30) UNIQUE NOT NULL,
user_password VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
salt VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
is_admin TINYINT(1) NOT NULL DEFAULT '0',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_plugin ON users(plugin);
CREATE INDEX idx_username ON users(username);
CREATE INDEX idx_user_password ON users(user_password);
Now in the main controller add this lines MyApp.pm inside sub start up:
# Router
my $r = $self->routes;
# # # Normal route to controller
$r->get('/')->to('Example#welcome');
#load the login form
$r->get('/login')->to(
controller => 'Login',
action => 'login'
);
#submit the login form
$r->post('/login')->to(
controller => 'Login',
action => 'user_login'
);
my $auth_required = $r->under('/')->to('Login#user_exists');
$auth_required->get('/welcome')->to(
controller => 'User', action => 'welcome',
)
Now lets add an the login link to templates/example/welcome.html.ep
:
<%= $msg %>
<%= link_to 'here' => '/login' %> to login.
Now lets define the login controller by creating this file:
package My_App::Controller::Login;
use Mojo::Base 'Mojolicious::Controller', -signatures;
use Data::Dumper;
use Digest;
use MIME::Base64;
use Mojolicious::Plugin::Authentication;
use lib 'lib';
use DBI;
use DBD::MariaDB;
my $dsn = "DBI:MariaDB:database=$ENV{DB_NAME};host=$ENV{DB_HOST};port={$ENV{DB_PORT}";
my $dbh = DBI->connect($dsn, $ENV{DB_USER}, $ENV{DB_PASSWORD});
sub login($self) {
$self->render(
template => 'login',
error => $self->flash('error')
);
}
sub user_login($self) {
# From the form
my $password = $self->param('password');
my $username = $self->param('username');
# auth plugin setup
$self->app->plugin(
'authentication' => {
autoload_user => 1,
wickedapp => 'YouAreLogIn',
load_user => sub($c, $user_id) {
if ($user_id) {
my $query = "<";
SELECT t1.id,
t1.username,
t1.user_password,
t1.salt,
t1.is_admin
FROM users t1 WHERE t1.id = ?
SQL
my $sth = $dbh->prepare($query);
$sth->execute($user_id);
my $user = $sth->fetchrow_hashref();
return $user
}
return;
},
validate_user => sub($c, $user, $pass, $extradata) {
my $user_key = $self->validate_user_login($user, $password);
}
}
);
my $auth_key = $self->authenticate($username, $password);
if ($auth_key) {
$self->flash(message => 'Login Success.');
$self->session(user => $auth_key);
return $self->redirect_to('/welcome');
} else {
$self->flash(error => 'Invalid username or password.');
$self->redirect_to('login');
}
}
# validate the user login
sub validate_user_login {
my ($self, $username, $password, $extradata) = @_;
SELECT t1.id,
t1.username,
t1.user_password,
t1.salt,
t1.is_admin
FROM users t1 WHERE t1.username = ?
SQL
my $sth = $dbh->prepare($query);
$sth->execute($password);
my $user = $sth->fetchrow_hashref();
my $id = $user->{id};
my $db_password = $user->{user_password};
my $salt = $user->{salt};
if (!defined $id) {
return 0;
} else {
return validate_password($password, $db_password, $salt) ? $id : undef;
}
return 1;
}
sub validate_password($form_password, $db_password, $salt) {
# $salt = decode_base64($salt);
my $cost = 12;
my $bcrypt = Digest->new(
'Bcrypt',
cost => $cost,
salt => decode_base64($salt)
);
if ($bcrypt->add($form_password)->b64digest() eq $db_password) {
return 1;
}
return 0;
}
sub user_exists($c) {
if ($c->session('user')) {
return 1;
} else {
$c->redirect_to('login');
}
}
1;
Now lets add the login form:
class="container">
class="card col-sm-6 mx-auto">
class="card-header text-center">
User Sign In
/> />
method="post" action='/login'>
class="form-control"
id="username"
name="username"
type="text" size="40"
placeholder="Enter Username"
/>
/>
class="form-control"
id="password"
name="password"
type="password"
size="40"
placeholder="Enter Password"
/>
/>
class="btn btn-primary" type="submit" value="Sign In">
/> />
% if ($error) {
class="error" style="color: red">
<%= $error %>
%}
Lets refactor the db connection a bit by creating an DB package in my_app/lib folder.
package MyDatabase;
use strict;
use warnings;
use DBI;
use DBD::MariaDB;
use Carp 'croak';
use Exporter::NoWork;
sub db_handle {
my $dsn = "DBI:MariaDB:database=$ENV{DB_NAME};host=$ENV{DB_HOST};port={$ENV{DB_PORT}";
my $dbh = DBI->connect($dsn, $ENV{DB_USER}, $ENV{DB_PASSWORD});
return $dbh;
}
At this point we can use an small script to insert an user in our db:
use strict;
use warnings;
use Digest::SHA qw(sha256_base64);
use Digest;
use MIME::Base64;
use lib 'lib';
use MyDatabase qw(db_handle);
use feature 'say';
my $salt = sha256_base64(time . rand() . $$);
$salt = substr($salt, 0, 16);
chomp $salt;
my $cost = 12;
my $bcrypt = Digest->new('Bcrypt', cost => $cost, salt => $salt);
my $sql = <<"SQL";
INSERT INTO users (
salt,
username,
user_password,
is_admin)
VALUES ( ?, ?, ?, ?)
SQL
my $dbh = db_handle();
my $r = $dbh->do($sql_license, undef, encode_base64($salt), 'Diana', $bcrypt->add('password')->b64digest(), 0);
Remember to add the dependencies to the carton file, use the new lib in Login controller and run the script to insert the new user.
Login in the app should be possible at this moment.
The last thing we need to do is provide an unit test for our app for this create an file in the t folder of you app with the following content:
use Mojo::Base -strict;
use Test2::V0;
use Test::Mojo;
use DBI;
use Dotenv;
use DBD::Mock::Session::GenerateFixtures;
use Sub::Override;
use MyDatabase qw(db_handle);
use lib 'lib';
use feature 'say';
my $mock_dumper = DBD::Mock::Session::GenerateFixtures->new({dbh => db_handle() });
my $t = Test::Mojo->new('MyApp');
$t->get_ok('/')->status_is(200);
my $login_url = $t->tx->res->dom->find('a')->grep(qr/login/)->map(attr => 'href')->first;
$t->post_ok($login_url => form => { username => 'Diana', password => 'password' })->status_is(200)
done_testing();
With your server up so it will connect to the run the unit test this will produce an mock file like this one:
[
{
"results" : [
[
1,
"Diana",
"IDRBjhp5KGuSQ8gqpc8bzSdU898DMK0",
"eFV0eC96YWpJMUJwU3h2WA==",
0
],
[]
],
"statement" : "SELECT t1.id, t1.username, t1.user_password, t1.salt, t1.is_admin FROM users t1 WHERE t1.username = ?",
"bound_params" : [
"Diana"
],
"col_names" : [
"id",
"username",
"user_password",
"salt",
"is_admin"
]
},
{
"bound_params" : [
1
],
"statement" : "SELECT id, username, user_password, salt, is_admin FROM users WHERE id = ?",
"results" : [
[
1,
"Diana",
"IDRBjhp5KGuSQ8gqpc8bzSdU898DMK0",
"eFV0eC96YWpJMUJwU3h2WA==",
0
],
[]
],
"col_names" : [
"id",
"username",
"user_password",
"salt",
"is_admin"
]
}
]
After this done do docker compose down
and then edit the unit test:
my $override = Sub::Override->new();
# remove the dbh as an argument
my $mock_dumper = DBD::Mock::Session::GenerateFixtures->new();
# replace the dbh with the mocked one
$override->replace('MyDatabase::db_handle' => sub {return $mock_dumper->get_dbh});
Now you should be able to run unit test isolated from the world wide web