Poet::...torial(3pm)

πŸ“¦
version 0.16 Β· 2 versions
  • 0.16 Β· debian bookworm, debian trixie, ubuntu noble, ubuntu resolute, ubuntu stonking (latest)
  • 0.16 Β· ubuntu jammy
, in debian bookworm, debian trixie, ubuntu noble, ubuntu resolute, ubuntu stonking

πŸ’– Poet::...torial(3pm) πŸ’–

okay bestie, welcome to Poet::Manual::Tutorial β€” yeah it's filed under User Contributed Perl Documentation in section 3pm, and it's literally the cutest way to learn Poet. grab a strawberry πŸ“ and let's go.


✨ NAME ✨

Poet::Manual::Tutorial β€” it's giving "Poet tutorial", no cap πŸ’…


πŸ¦‹ DESCRIPTION πŸ¦‹

so this tutorial is a whole tour of Poet, and we do it by building a sample web app β€” specifically a micro-blog, because apparently that's the popular "hello world" for web frameworks. iconic behavior honestly. 😌 Shoutout to Dancer and Flask <https://github.com/mitsuhiko/flask/tree/master/examples/flaskr/> for the inspo, we love a collab. πŸ’–


🌸 INSTALLATION 🌸

first things first, we install Poet plus a few support modules. get comfy.

if you don't have cpanminus (cpanm) yet, go grab it here <http://search.cpan.org/perldoc?App::cpanminus#INSTALLATION>. then run:

cpanm -S --notest DateTime DBD::SQLite Poet Rose::DB::Object
  • skip the -S if you don't have root β€” in that case cpanminus installs Poet and its prereqs into ~/perl5 instead. bestie accommodation. 🫢
  • skip the --notest if you actually want to run all the installation tests. heads up tho: that makes it take about four times as long. 😭 your call.

πŸŽ€ SETUP πŸŽ€

you should now have a poet app installed, like:

% which poet
/usr/local/bin/poet

run this to create the initial environment:

% poet new Blog
blog/.poet_root
blog/bin/app.psgi
blog/bin/get.pl
...
Now run 'blog/bin/run.pl' to start your server.

the app name, Blog, gets used in app-specific class names. it's also the default directory name (blog) β€” tho you can move that directory literally wherever you want, it's not a big deal. πŸ’

now start your server with:

% blog/bin/run.pl

and you should see something like:

Running plackup --Reload ... --env development --port 5000
Watching ... for file updates.
HTTP::Server::PSGI: Accepting connections at http://0:5000/

and boom, you should be able to hit that URL and see the Poet welcome page. slay. πŸ”₯

so in Poet, your entire web site lives inside a single directory hierarchy called the environment. it's got subdirectories for configuration, libraries, Mason components (that's templates bestie), static files, etc. very organized, very main character. πŸ‘‘

from now on, every file we create in this tutorial is assumed to be under the environment root. periodt.

🌈 DATA LAYER (MODEL) 🌈

for any website it's a good idea to have a well-defined, object-oriented model that you go through to retrieve and change data. Poet and Mason don't really have opinions on how you do this, so we're gonna make some minimal reasonable choices here and keep it moving. πŸšΆβ™€οΈ

for this demo we'll represent blog articles with a single sqlite table. create a file db/schema.sql with:

create table if not exists articles (
  id integer primary key autoincrement,
  content string not null,
  create_time timestamp not null,
  title string not null
);

then run:

% cd blog
% sqlite3 -batch data/blog.db < db/schema.sql

we're gonna use Rose::DB::Object to give us a nice object-oriented API to our data (DBIx::Class would work just as well, fyi). create a file lib/Blog/DB.pm to tell Rose how to connect to our database:

lib/Blog/DB.pm:

package Blog::DB;
use Poet qw($poet);
use strict;
use warnings;
use base qw(Rose::DB);

__PACKAGE__->use_private_registry;
__PACKAGE__->register_db(
    driver   => 'sqlite',
    database => $poet->data_path("blog.db"),
);

1;

and a file lib/Blog/Article.pm to represent the articles table:

lib/Blog/Article.pm:

package Blog::Article;
use Blog::DB;
use strict;
use warnings;
use base qw(Rose::DB::Object);

__PACKAGE__->meta->setup(
    table => 'articles',
    auto  => 1,
);
__PACKAGE__->meta->make_manager_class('articles');
sub init_db { Blog::DB->new }

1;

basically this gives us:

  • a Blog::Article class with a constructor for inserting articles, plus instance methods for each of the columns πŸ’…
  • a Blog::Article::Manager class (autogenerated, she's self-made) for searching for and retrieving multiple articles

see Rose::DB::Object::Tutorial for more info, iykyk. πŸ“š


🧚 QUICK VARS AND UTILITIES 🧚

in lib/Blog/DB.pm above, we have:

use Poet qw($poet);

followed by:

database => $poet->data_path("blog.db"),

so $poet is the global Poet::Environment object β€” it hands you info about the environment and all its directory paths. we use it here to get the full path to our sqlite database without hardcoding our environment root. not me being flexible and portable. 😌✨

more generally, $poet is one of several special Poet "quick vars" that you can import into any package β€” you literally just include it on the use Poet line. another important one is $conf, which gives you access to configuration:

use Poet qw($conf $poet);
...
my $value = $conf->get('key', 'default');

you can also import whole sets of utilities the exact same way β€” e.g. :file for file utilities and :web for web-related utilities. see Poet::Import for the full list of Poet vars and utility sets. the range? unmatched. πŸ’œ


⭐ CONFIGURATION ⭐

Poet configuration files live in the conf subdirectory. they're in YAML <http://www.yaml.org/> form, and they get merged in a particular order to create a single configuration hash. very demure, very mindful merging.

for this tutorial we can ignore everything except conf/local.cfg. it currently contains:

layer: development

server.port: 5000

this says you're in development mode (so you'll see errors right in the browser, etc.) and running on port 5000. 😎

you'll need to add one more entry:

server.load_modules:
    - Blog::Article

this says to load Blog::Article, our model, on server startup. gotta warm her up before the party. πŸŽ‰

see Poet::Conf for More information on configuration. πŸ’–


πŸ¦‹ MASON PAGES AND COMPONENTS πŸ¦‹

Mason is the templating engine you'll use to render pages (that's the view), and it's ALSO responsible for routing URLs to specific pieces of code (that's the controller). so yeah, obviously most of the rest of this tutorial is gonna be about Mason. she does everything, honestly. main character energy fr fr. πŸ‘‘

Mason's basic building block is the component β€” a file with a mix of Perl and HTML. all components live under the subdirectory comps; in Mason parlance that's called the component root.

given a URL, Mason dispatches to a top-level component. that component decides the overall page layout, and then it may call other components to fill in the details. delegation queen. πŸ’…

poet new generated a few starter components for us, but we're not gonna use those, so let's clear them out by running:

rm -fR comps; mkdir comps

now here's our first component to serve the home page, comps/index.mc:

comps/index.mc:

   1  <html>
   2    <head>
   3      <link rel="stylesheet" href="/static/css/blog.css">
   4      <title>My Blog: Home</title>
   5    </head>
   6    <body>
   7
   8      <h2>Welcome to my blog.</h2>
   9
  10      <& all_articles.mi &>
  11
  12      <a href="/new_article">Add an article</a>
  13
  14    </body>
  15  </html>

any component with a .mc extension is considered a top-level component. index.mc is a special path β€” it matches the URI of its directory, in this case /. (for more special paths and the deets on how Mason finds page components, see Mason::Manual::RequestDispatch.) πŸ—ΊοΈ

most of this component is just plain HTML, and it gets output exactly as written. the single piece of special Mason syntax here is:

  10  <& all_articles.mi &>

this is a component call β€” it invokes another component, and that component's output gets inserted right in place. 🫢

πŸŽ€ %-lines, substitution tags, <%init> blocks πŸŽ€

next we create comps/all_articles.mi. because it has an .mi extension instead of .mc, it's an internal rather than a top-level component β€” meaning it cannot be reached by an external URL. it can only be reached via a component call from another component. private girly. πŸ™ˆ

comps/all_articles.mi

   1  % if (@articles) {
   2  <b>Showing <% scalar(@articles) %> article<% @articles > 1 ? "s" : "" %>.</b>
   3  <ul class="articles">
   4  %   foreach my $article (@articles) {
   5    <li><& article/display.mi, article => $article &></li>
   6  %   }
   7  </ul>
   8  % }
   9  % else {
  10  <p>No articles yet.</p>
  11  % }
  12
  13  <%init>
  14  my @articles = @{ Blog::Article::Manager->get_articles
  15      (sort_by => 'create_time DESC') };
  16  </%init>

three new pieces of syntax here, let's break them down bestie:

  • Init block β€” the <%init> block on lines 13-16 specifies a block of Perl code that runs first when this component is called. in this case it fetches and sorts the list of articles into a lexical variable @articles. get it, girl. πŸ“₯
  • %-lines β€” %-lines, i.e. lines beginning with a single %, are treated as Perl rather than HTML. they're especially good for loops and conditionals. πŸŒ€
  • Substitution tags β€” this line:
   2  <b>Showing <% scalar(@articles) %> article<% @articles > 1 ? "s" : "" %>.</b>

shows two substitution tags. code within <% and %> is treated as a Perl expression, and the result of that expression gets output right in place. ✨

we see another component call here: article/display.mi, which displays a single article. we pass the article object in a name/value argument pair. components can live in different directories, and component paths can be relative or absolute. flexible icon. πŸ’…

πŸ’œ Attributes πŸ’œ

next we create comps/article/display.mi. (it's in a new subdirectory, which shows you can freely organize components among different directories. aesthetic.) πŸ—‚οΈ

comps/article/display.mi:

   1  <%class>
   2  use Date::Format;
   3  my $date_fmt = "%A, %B %d, %Y  %I:%M %p";
   4  has 'article' => (required => 1);
   5  </%class>
   6
   7  <div class="article">
   8    <h3><% $.article->title %></h3>
   9    <h4><% $.article->create_time->strftime($date_fmt) %></h4>
  10    <% $.article->content %>
  11  </div>

the <%class> block on lines 1-4 specifies a block of Perl code to place near the top of the generated component class, outside of any methods. this is the place to use modules, declare permanent constants/variables, declare attributes with has, and define helper methods. most components of any complexity will probably have a <%class> section. it's giving organizational queen. 🧚

on line 4 we declare a single incoming attribute, article. it is required, meaning that if all_articles.mi had forgotten to pass it, we'd get a fatal error. no skipping steps in this house. πŸ’€

throughout this component, we refer to the article attribute via the expression:

$.article

this not-quite-valid-Perl syntax gets transformed behind the scenes to:

$self->article

magic, honestly. ✨

...and it's one of those rare cases in Mason where we straight-up invent brand-new syntax on top of Perl, because we want attributes and method calls to be as convenient as they possibly can be. The transformation itself is done by the DollarDot plugin β€” it's in the default plugins list, but you can absolutely leave it out if source filtering gives you the ick. :)


πŸŽ€ Content wrapping, autobases, inheritance, method modifiers πŸŽ€

Okay so now we gotta handle the URL /new_article, which is linked from the home page. We do that with our second page component, comps/new_article.mc. For now it's literally just HTML, no thoughts head empty. πŸ’…

comps/new_article.mc:

   1  <html>
   2    <head>
   3      <link rel="stylesheet" href="/static/css/blog.css">
   4      <title>My Blog: Home</title>
   5    </head>
   6    <body>
   7
   8      <h2>Add an article</h2>
   9
  10      <form action="/article/publish" method=post>
  11        <p>Title: <input type=text size=30 name=title></p>
  12        <p>Text:</p>
  13        <textarea name=content rows=20 cols=70></textarea>
  14        <p><input type=submit value="Publish"></p>
  15      </form>
  16
  17    </body>
  18  </html>

So notice how comps/index.mc and comps/new_article.mc share the exact same outer HTML template? And other pages will too. Copy-pasting that everywhere would be so tedious, and bestie, we don't have to. We lift the common pieces out of comps/index.mc and comps/new_article.mc and drop them into a new component called comps/Base.mc:

comps/Base.mc:

   1  <%augment wrap>
   2    <html>
   3      <head>
   4        <link rel="stylesheet" href="/static/css/mwiki.css">
   5        <title>My Blog</title>
   6      </head>
   7      <body>
   8        <% inner() %>
   9      </body>
  10    </html>
  11  </%augment>

When any page in our hierarchy gets rendered, comps/Base.mc gets control first. It renders the upper chunk of the template (lines 2-7), then calls the specific page component (line 8), then renders the lower chunk of the template (lines 9-10). Iconic. πŸ‘‘

Now we can strip everything except the inside of the <body> tag out of comps/index.mc and comps/new_article.mc. Chef's kiss.

comps/index.mc:

<h2>Welcome to my blog.</h2>

<& all_articles.mi &>

<a href="/new_article">Add an article</a>

comps/new_article.mc

<h2>Add an article</h2>

<form action="/article/publish" method=post>
  <p>Title: <input type=text size=30 name=title></p>
  <p>Text:</p>
  <textarea name=content rows=20 cols=70></textarea>
  <p><input type=submit value="Publish"></p>
</form>

More details on how content wrapping works here. ✨


🌸 Form handling, pure-perl components 🌸

/new_article.mc posts to /article/publish. So let's build a component to handle that, called comps/article/publish.mp. It outputs nothing β€” it just takes action and redirects.

comps/article/publish.mp:

   1  has 'content';
   2  has 'title';
   3
   4  method handle () {
   5      my $session = $m->session;
   6      if ( !$.content || !$.title ) {
   7          $session->{message}   = "Content and title required.";
   8          $session->{form_data} = $.args;
   9          $m->redirect('/new_article');
  10      }
  11      my $article = Blog::Article->new(
  12          title       => $.title,
  13          content     => $.content,
  14          create_time => DateTime->now( time_zone => 'local' )
  15      );
  16      $article->save;
  17      $session->{message} = sprintf( "Article '%s' saved.", $.title );
  18      $m->redirect('/');
  19  }

The .mp extension means pure-perl component. Other than the package and use Moose lines that Mason generates for you, it looks exactly like a regular Perl class. You could get the same result with a .mc component holding a single <%class> block, but this way is easier and way more self-documenting. 🫢

On lines 1 and 2 we declare incoming attributes. Because this is a top-level page component, those attributes get populated with our POST parameters.

On line 4 we define a handle method to validate the POST parameters, create the article, and redirect. handle is one of the structural methods Mason calls initially on all top-level page components β€” the default one just renders the component's HTML like we've seen before. Defining handle is the way to take an action without rendering anything, which is perfect for form actions. (And fr fr, it's always better to redirect after a form action than to display content directly.)

The method keyword comes from Method::Signatures::Simple, which is imported into components by default; see Mason::Component::Moose.

On line 5 we grab the Plack session via $m->session. That's one of a handful of web-related methods that are only available in Poet.

On lines 7 and 17 we set a message in the session that we want to show on the next page. But instead of making that work for one specific page, let's add generic code to the template in Base.mc:

comps/Base.mc:

   7      <body>
=>  8  % if (my $message = delete($m->session->{message})) {
=>  9        <div class="message"><% $message %></div>
=> 10  % }
  11        <% inner() %>
  12      </body>

Now any page can drop a message in the session and it'll show up on just the next page. Obsessed.

On line 8, we stash the POST data in the session so we can repopulate the form with it β€” we'll do that in the next chapter. $.args is a special component attribute that holds all the arguments passed to the component.


πŸ¦‹ Filters πŸ¦‹

We need to change comps/new_article.mc so it repopulates the form with the submitted values when validation fails.

comps/new_article.mc:

     1  <h2>Add an article</h2>
     2
==>  3  % $.FillInForm($form_data) {{
     4  <form action="/article/publish" method=post>
     5    <p>Title: <input type=text size=30 name=title></p>
     6    <p>Text:</p>
     7    <textarea name=content rows=20 cols=70></textarea>
     8    <p><input type=submit value="Publish"></p>
     9  </form>
==> 10  % }}
    11
==> 12  <%init>
==> 13  my $form_data = delete($m->session->{form_data});
==> 14  </%init>

On lines 3 and 10 we wrap the form in a filter. A filter takes a content block as input and returns a new content block that gets output in its place. In this case the FillInForm filter uses HTML::FillInForm to fill in the form from the values in $form_data.

Mason ships a few built-in filters, and other plugins bring more β€” for example FillInForm is provided by the HTMLFilters plugin.

Another filter this plugin gives you is HTMLEscape, or H for short. We should use that in /article/display.mi when displaying the article title, just in case it's got any HTML-unfriendly characters lurking in it:

<h3><% $.article->title |H %></h3>

See Mason::Manual::Filters for more about using (and creating!) filters. πŸ’–


✨ POET SCRIPTS ✨

Up til now all our code has lived in Mason components. But now say we want a cron script to purge blog entries older than a configurable number of days. That script, obviously, needs access to all the same Poet features our components get.

Run this from anywhere inside your environment:

% poet script purge_old_entries.pl
...bin/purge_old_entries.pl

Poet made a stub script for us inside bin. Let's peek:

#!/usr/local/bin/perl
use Poet::Script qw($conf $poet);
use strict;
use warnings;

Line 2 of the script initializes the Poet environment. Which means Poet does several things:

  • Searches upwards from the script for the environment root (as marked by the .poet_root file).
  • Reads and parses your configuration.
  • Unshifts onto @INC the lib/ subdirectory of your environment, so that you can use your application modules.
  • Imports the specified quick vars β€” in this case $conf and $poet β€” into the script namespace. See Poet::Import.

Poet initialization has to happen exactly once per process, before any Poet features are used. In fact, take a look at bin/run.pl β€” which was generated for you initially β€” and you'll see that it does use Poet::Script as well. That initializes Poet for the entire web environment.

Now we can fill out our purge script:

#!/usr/local/bin/perl
use Poet::Script qw($conf);
use Blog::Article;
use strict;
use warnings;

my $days_to_keep = $conf->get( 'blog.days_to_keep' => 365 );
my $min_date = DateTime->now->subtract( days => $days_to_keep );
Blog::Article::Manager->delete_articles(
    where => [ create_time => { lt => $min_date } ] );

In line 2, we've eliminated the unneeded $poet.

In line 7, we get $days_to_keep from configuration, giving it a reasonable default if there's nothing in configuration.

Finally in lines 8-10 we delete articles less than the minimum date. Slay. πŸŽ€


🌈 FILES FROM THIS TUTORIAL 🌈

The final set of files for our blog demo live in the eg/blog directory of the Poet distribution, or you can view them at github here: https://github.com/jonswar/perl-poet/tree/master/eg/blog.


⭐ SEE ALSO ⭐

  • Poet

πŸ’œ AUTHOR πŸ’œ

Jonathan Swartz <swartz@pobox.com>


This software is copyright (c) 2012 by Jonathan Swartz.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.


And that's a wrap bestie 🫢 this page was brought to you by perl v5.34.0, dated 2022-06-18 β€” the Poet::...torial(3pm) era, forever iconic. go build something cute πŸ’…βœ¨

πŸ“¦ where this page comes from