Upload
marcus-ramberg
View
4.345
Download
0
Tags:
Embed Size (px)
DESCRIPTION
A practical introduction to using the Catalyst MVC framework, showing how to make a simple application with Catalyst, DBIx::Class and TT You can also find the video for this presentation at http://www.devtv.eu/wp/2009/04/marcus-ramberg-introduction-to-catalyst/
Citation preview
Nordaaker
Introduction to Catalyst
Now we’re cooking with fire
About Marcus
• Oslo Perl Monger
• Catalyst Release Manager
• Nordaaker Ltd
Today’s dish
• PizzaShop
• Ingredients• DBIx::Class
• Extensible and flexible ORM
• Template Toolkit• Template Processing System
• Catalyst • The Elegant MVC Framework
MVC
Installing from CPAN
• cpan Task::Catalyst• [DBIx::Class Support]
• [Template::Toolkit Support]
• [Authentication/Authorization Support]
• [HTML::FormFu Support]
Preparing the skeleton
• Every web-app needs a lot of the same things
• So you keep repeating a lot• Talk to web server• Load config files• Connect to the database• render a template• etc ...
Yak Shaving
Setting up the Catalyst app
# Catalyst gets rid of repeat work
~Source $ catalyst.pl PizzaShop
created "PizzaShop"
created "PizzaShop/script"
created "PizzaShop/lib"
created "PizzaShop/root"
....
created "PizzaShop/script/pizzashop_fastcgi.pl"
created "PizzaShop/script/pizzashop_server.pl"
created "PizzaShop/script/pizzashop_test.pl"
created "PizzaShop/script/pizzashop_create.pl"
~/Source $
Our skeleton
• Is a full CPAN package
• Contains standalone and fastcgi server scripts
• loads config automatically
• testing stubs
• helpers to create more parts of your app
Generated files
Command-Central:PizzaShop marcus$ ls * Changes Makefile.PL README pizzashop.conf
lib:PizzaShop PizzaShop.pm
root:favicon.ico static
script:pizzashop_cgi.pl pizzashop_fastcgi.pl pizzashop_test.plpizzashop_create.pl pizzashop_server.pl
t:01app.t 02pod.t 03podcoverage.t
in ~/Source/PizzaShop
$ cat README
Run script/pizzashop_server.pl to test the application.
$ ./script/pizzashop_server.pl
[debug] Debug messages enabled
[debug] Statistics enabled
[debug] Loaded plugins:
.----------------------------------------------------------------------------.
| Catalyst::Plugin::ConfigLoader 0.20 |
| Catalyst::Plugin::Static::Simple 0.20 |
'----------------------------------------------------------------------------'
[debug] Loaded dispatcher "Catalyst::Dispatcher"
[debug] Loaded engine "Catalyst::Engine::HTTP"
[debug] Found home "/Users/marcus/Source/PizzaShop"
[debug] Loaded Config "/Users/marcus/Source/PizzaShop/pizzashop.conf
...
Continued
[debug] Loaded Private actions:
.----------------------+--------------------------------------+--------------.
| Private | Class | Method |
+----------------------+--------------------------------------+--------------+
| /default | PizzaShop::Controller::Root | default |
| /end | PizzaShop::Controller::Root | end |
| /index | PizzaShop::Controller::Root | index |
'----------------------+--------------------------------------+--------------'
[debug] Loaded Path actions:
.-------------------------------------+--------------------------------------.
| Path | Private |
+-------------------------------------+--------------------------------------+
| / | /default |
| / | /index |
'-------------------------------------+--------------------------------------'
[info] PizzaShop powered by Catalyst 5.7099_03
You can connect to your server at http://command-central:3000
And in your browser
Standalone server options
Usage: pizzashop_server.pl [options]
Options: -d -debug force debug mode -f -fork handle each request in a new process (defaults to false) -? -help display this help and exits -host host (defaults to all) -p -port port (defaults to 3000) -k -keepalive enable keep-alive connections -r -restart restart when files get modified (defaults to false) -rd -restartdelay delay between file checks -rr -restartregex regex match files that trigger a restart when modified (defaults to '\.yml$|\.yaml$|\.conf|\.pm$') -restartdirectory the directory to search for modified files, can be set mulitple times (defaults to '[SCRIPT_DIR]/..') -follow_symlinks follow symlinks in search directories (defaults to false. this is a no-op on Win32)
Let’s check on the action that did this
# In lib/PizzaShop/Controller/Root.pm
sub index :Path :Args(0) {
my ( $self, $c ) = @_;
# Hello World
$c->response->body( $c->welcome_message );
}
Moving on to TT
• Kind of boring to write all your html in .pm files
• Would like to render some templates kthx
• Will need to create a View
• Catalyst can do that for us tho
Create script
Command-Central:PizzaShop marcus$ ./script/pizzashop_create.pl
Usage:
pizzashop_create.pl [options] model|view|controller name [helper]
[options]
Options:
-force don't create a .new file where a file to be created exists
-mechanize use Test::WWW::Mechanize::Catalyst for tests if available
-help display this help and exits
Examples:
pizzashop_create.pl controller My::Controller
pizzashop_create.pl controller My::Controller BindLex
pizzashop_create.pl -mechanize controller My::Controller
pizzashop_create.pl view My::View
pizzashop_create.pl view MyView TT
pizzashop_create.pl view TT TT
pizzashop_create.pl model My::Model
pizzashop_create.pl model SomeDB DBIC::Schema MyApp::Schema create=dynamic\
dbi:SQLite:/tmp/my.db
pizzashop_create.pl model AnotherDB DBIC::Schema MyApp::Schema create=static\
dbi:Pg:dbname=foo root 4321
Create the view
# Let’s call it Default
$ ./script/pizzashop_create.pl view Default TT
exists "/Users/marcus/Source/PizzaShop/script/../lib/PizzaShop/View"
exists "/Users/marcus/Source/PizzaShop/script/../t"
created "/Users/marcus/Source/PizzaShop/script/../lib/PizzaShop/View/Default.pm"
created "/Users/marcus/Source/PizzaShop/script/../t/
$
lib/PizzaShop/View/Default.pm
package PizzaShop::View::Default;
use strict;
use base 'Catalyst::View::TT';
__PACKAGE__->config(TEMPLATE_EXTENSION => '.tt');
1;
# And a POD template
Connecting the renderer with the view
• catalyst.pl has already done this for you:
sub end : ActionClass('RenderView') {}
• RenderView will try to render a template unless the body is set or there is a redirect in place
• Uses $c->config->{default_view} and $c->stash->{current_view} to decide if more than one view
So let’s clear the body
# In lib/PizzaShop/Controller/Root.pm
sub index :Path :Args(0) {
my ( $self, $c ) = @_;
# Hello World
$c->response->body( $c->welcome_message );
}
In the browser
Ok, let’s make root/index.tt
<html><head><title>Menu</title></head><body><ul>[%FOREACH pizza IN [ 1 .. 10 ] %]<li>Pizza nr [% pizza %]</li>[% END %]</ul></body></html>
In the browser
What comes before VC ?
• It’s time for the Model of course
• Like with any good cooking show, I have been cheating
• Here is a SQL database I prepared the other night:
product.sql
CREATE TABLE product (
id INT PRIMARY KEY AUTOINCREMENT,
name TEXT,
description TEXT,
price INT,
category_id INT REFERENCES category
);
CREATE TABLE category (
id INT PRIMARY KEY AUTOINCREMENT,
name TEXT
);
Doing the dirty work
★ So I stuffed this thing into a sqlite database:
$ cat product.sql | sqlite product.db
★ Then let catalyst make a model from it
$ ./script/pizzashop_create.pl model DB DBIC::Schema PizzaShop::Schema \
create=static dbi:SQLite:product.db
exists "/Users/marcus/Source/PizzaShop/script/../lib/Pizza/Shop/Model"
exists "/Users/marcus/Source/PizzaShop/script/../t"
Dumping manual schema for PizzaShop::Schema to directory /Users/marcus/Source/PizzaShop/script/../lib ...
Schema dump completed.
created "/Users/marcus/Source/PizzaShop/script/../lib/PizzaShop/Model/DB.pm"
created "/Users/marcus/Source/PizzaShop/script/../t/model_DB.t"
The Generated Schema
package Pizza::Shop::Schema::Product;use strict;use warnings;use base 'DBIx::Class';__PACKAGE__->load_components("Core");__PACKAGE__->table("product");__PACKAGE__->add_columns( "id", { data_type => "INT", is_nullable => 0, size => undef }, "name", { data_type => "TEXT", is_nullable => 0, size => undef }, "description", { data_type => "TEXT", is_nullable => 0, size => undef }, "price", { data_type => "INT", is_nullable => 0, size => undef }, "category", { data_type => "INT", is_nullable => 0, size => undef },);__PACKAGE__->set_primary_key("id");__PACKAGE__->belongs_to( "category_id", "Pizza::Shop::Schema::Category", { id => "category_id" } );
Let’s get cooking
• Our ingredients are pretty much ready now.
• Create a controller to put our Menu in:PizzaShop $ ./script/pizzashop_create.pl controller Menu
created "/Users/marcus/Source/PizzaShop/script/../lib/PizzaShop/Controller/Menu.pm"
created "/Users/marcus/Source/PizzaShop/script/../t/controller_Menu.t"
• Let’s start with something easy
Peeking at pizzas
# The controller code needed for a view action:
sub detail : Local Args(1) {
my ($self,$c,$pizza_id) = @_;
$c->stash->{pizza} =
$c->model('DB::Product')->find($product_id);
$c->detach('/default') unless $c->stash->{pizza};
}
# root/menu/detail.tt
[%PROCESS header.tt%]
<h1>[% pizza.name %] ([% pizza.category.name %])</h1>
<h2>[% pizza.description %]</h2>
[%PROCESS footer.tt%]
Header & Footer
# header.tt<html>[% DEFAULT title ='Welcome' %]<head><title> PizzaShop - [% title %]</head><body>
# footer.tt</body></html>
Alternative: wrapper.tt
• Add WRAPPER=>’...’ to TT view config
• wrapper file calls [% content %]
• Personal preference
• Need to solve templates that does not need wrapper
And when we restart the server:
| /menu/detail | PizzaShop::Controller::Menu | detail |
Neat distraction:
DBIC_TRACE=1 ./script/pizzashop_server.pl
SELECT me.id, me.name, me.description, me.price, me.category FROM product me WHERE ( ( me.id = ? ) ): '1'
SELECT me.id, me.name FROM category me WHERE ( ( ( me.id = ? ) ) ): '1'
#which kind of sucks, so let’s do this:$c->model('DB::Product') ->search({id=>$pizza_id}, {prefetch=>[qw/category/]}) ->first;SELECT me.id, me.name, me.description, me.price, me.category, category.id, category.name FROM product me JOIN category category ON ( category.id = me.category ) WHERE ( me.id = ? ): '1'
Listing items
Find this bit in lib/PizzaShop/Controller/Menu.pm
sub index :Path :Args(0) {
my ( $self, $c ) = @_;
$c->response->body(
'Matched PizzaShop::Controller::Menu in Menu.');
}
Listing items
Change it slightly like this
sub index :Path :Args(0) {
my ( $self, $c ) = @_;
$c->response->body(
'Matched PizzaShop::Controller::Menu in Menu.');
$c->stash->{menu}=$c->model(‘DB::Product’);
}
Listing items
And let’s create root/menu/index.tt:
[% PROCESS header.tt title=’Menu’ -%]
<h1>Menu</h1>
<ul>
[% WHILE( product=products.next ) -%]
<li><a href="[%c.uri_for('/product/view',product.id)%]">
[%product.name%]</a></li>
[% END -%]
</ul>
[% PROCESS footer.tt %]
Meanwhile, in the browser ...
Paging
In order to digest the full menu we’d like to split it up into pages. Let’s find this stanza in Menu.pm:$c->stash->{menu}= $c->model(DB::Menu’)And we’ll add this before the ;->search({},{ rows=>8, page=>$c->req->params->{page'} || 1 });
Then in the index.tt,
...
[%product.name%]</a></li>
[%END%]
</ul>
[% PROCESS pager.tt pager=menu.pager -%]
pager.tt
The resultset object provides access to a Data::Page object, which makes paging easy and reusable:
[% IF ( pager.current_page != pager.first_page) %]
<a href="[%c.req.uri_with({page=>pager.previous_page})%]">
« Previous</a>
[% END %]
[% first = ( pager.current_page > 9 ? (pager.current_page - 9) : 1 )%]
[% last = ( pager.current_page + 9 < pager.last_page ? (pager.current_page + 9) : pager.last_page ) %]
[% FOREACH page IN [first .. last] %]
[%IF (pager.current_page == page)%] [%page%] [% ELSE %]
<a href="[%c.req.uri_with({page=>pager.next_page})%]">[%page%]</a>
[%END%] [%END%]
[% IF ( pager.current_page != pager.last_page ) %]
<a href="[%c.req.uri_with({page=>pager.next_page})%]">Next »</a>
[% END %]
Et voila:
One, Two, Many
★ Wouldn’t it be nice to be able to filter by category?
★ Let's add another line to the index action:
$c->stash->{categories} =
$c->model('DB::Category')->search({},
{ join => [qw/ products /],
select => [ 'me.id','me.name',
{ count => 'products.id' } ],
as => [qw/id name product_count/],
group_by => [qw/ me.id /] });
And in index.tt
[% PROCESS categories.tt %]
# categories.tt:
<div style=”float:right”>
[% WHILE (category = categories.next) %]
<li> <a href="[%c.uri_for(
'/menu/by_category',category.id)%]">
[%category.name%]</a>
([%category.get_column(
'product_count')%])</li>
[%END%]
</div>
Results in:
Refactoring:
• The code Menu.pm is a bit messy
• should refactor it into a Resultset class
• $c->model(‘DB::Category’)
->with_counts()
• Left as an excercise for you.
Desert: Form generation and validation
• Let’s make another controller Restricted::Menu
• And change the base class
use parent 'Catalyst::Controller::HTML::FormFu';
• Also, make it dispatch to /menu
__PACKAGE__->config('path'=>'menu');
And add an action:
sub add : FormConfig Local Args(0) {
my ($self,$c) = @_;
my $form = $c->stash->{form};
my $product = $c->model('DB::Product')->new({});
$form->default_values($product);
if ($form->submitted && !$form->has_errors) {
$form->model->update( $product );
$c->res->redirect($c->uri_for('/menu'));
}
}
root/restricted/menu/add.tt
[% PROCESS header.tt title=’Add product’ -%][% form %][% PROCESS footer.tt-%]
root/forms/restricted/menu/add.yml
---action: indicator: submitelements: - type: Text name: name label: Title constraints: - Required - Word - type: Text name: description label: Description filters: - HTMLScrubber - type: Select name: category label: Category model_config: label_column: name id_column: id model: 'DB::Category’ - type: Submit name: submit add_attributes: value: Add to menu
Renders like:
★Ugly★HTML::FormFu includes
some sample css:examples/vertically-aligned/
A bit better
Bonus slides: Adding Auth
PizzaShop.pm:use Catalyst qw/-Debug
Authentication
ConfigLoader
Static::Simple
Session
Session::Store::FastMmap
Session::State::Cookie/;
Root.pm:sub login : Global Args(0) {
my ( $self, $c ) = @_;
if ($c->authenticate({
username => $c->req->params->{user},
login => $c->req->params->{pass} }) ) { ... }
}
Login template
root/login.tt:[%PROCESS header.tt%]<h2>Please log in</h2><form action="[%c.req.uri%]" method="post"> <fieldset> <p>[%alert%]</p> <dl> <dt>Login:</dt> <dd><input name="user" type="text" /></dd> <dt>Password:</dt> <dd><input name="pass" type="password" /></dd> </dd> <input type="submit" class="submit" /> </form>
[% PROCESS footer.tt%]
And configuration (PizzaShop.conf):
<authentication> default_realm dbic <realms> <dbic> <credential> class Password password_field password password_type clear </credential> <store> class DBIx::Class user_class DB::Users id_field username </store> </dbic></realms>
DB::Person?
• Just update the SQL:
CREATE TABLE person (
username VARCHAR(25) PRIMARY KEY
password VARCHAR(25)
);
• Then regenerate your schema to get the Person.pm file
And a simple restricted controller
package PizzaShop::Controller::Restricted;
...
sub auto :Private {
my ($self,$c) = @_;
return 1 if $c->user_exists;
$c->res->redirect($c->uri_for(‘/login’));
return 0;
}