Commit | Line | Data |
d442cc9f |
1 | =head1 NAME |
2 | |
3533daff |
3 | Catalyst::Manual::Tutorial::BasicCRUD - Catalyst Tutorial - Part 4: Basic CRUD |
d442cc9f |
4 | |
5 | |
6 | =head1 OVERVIEW |
7 | |
3533daff |
8 | This is B<Part 4 of 10> for the Catalyst tutorial. |
d442cc9f |
9 | |
10 | L<Tutorial Overview|Catalyst::Manual::Tutorial> |
11 | |
12 | =over 4 |
13 | |
14 | =item 1 |
15 | |
16 | L<Introduction|Catalyst::Manual::Tutorial::Intro> |
17 | |
18 | =item 2 |
19 | |
20 | L<Catalyst Basics|Catalyst::Manual::Tutorial::CatalystBasics> |
21 | |
22 | =item 3 |
23 | |
3533daff |
24 | L<More Catalyst Basics|Catalyst::Manual::Tutorial::MoreCatalystBasics> |
d442cc9f |
25 | |
26 | =item 4 |
27 | |
3533daff |
28 | B<Basic CRUD> |
d442cc9f |
29 | |
30 | =item 5 |
31 | |
3533daff |
32 | L<Authentication|Catalyst::Manual::Tutorial::Authentication> |
d442cc9f |
33 | |
34 | =item 6 |
35 | |
3533daff |
36 | L<Authorization|Catalyst::Manual::Tutorial::Authorization> |
d442cc9f |
37 | |
38 | =item 7 |
39 | |
3533daff |
40 | L<Debugging|Catalyst::Manual::Tutorial::Debugging> |
d442cc9f |
41 | |
42 | =item 8 |
43 | |
3533daff |
44 | L<Testing|Catalyst::Manual::Tutorial::Testing> |
d442cc9f |
45 | |
46 | =item 9 |
47 | |
3533daff |
48 | L<Advanced CRUD|Catalyst::Manual::Tutorial::AdvancedCRUD> |
49 | |
50 | =item 10 |
51 | |
d442cc9f |
52 | L<Appendices|Catalyst::Manual::Tutorial::Appendices> |
53 | |
54 | =back |
55 | |
56 | |
d442cc9f |
57 | =head1 DESCRIPTION |
58 | |
59 | This part of the tutorial builds on the fairly primitive application |
3533daff |
60 | created in Part 3 to add basic support for Create, Read, Update, and |
d442cc9f |
61 | Delete (CRUD) of C<Book> objects. Note that the 'list' function in Part |
62 | 2 already implements the Read portion of CRUD (although Read normally |
63 | refers to reading a single object; you could implement full read |
64 | functionality using the techniques introduced below). This section will |
65 | focus on the Create and Delete aspects of CRUD. More advanced |
66 | capabilities, including full Update functionality, will be addressed in |
3533daff |
67 | Part 9. |
d442cc9f |
68 | |
69 | You can checkout the source code for this example from the catalyst |
70 | subversion repository as per the instructions in |
71 | L<Catalyst::Manual::Tutorial::Intro> |
72 | |
3533daff |
73 | |
d442cc9f |
74 | =head1 FORMLESS SUBMISSION |
75 | |
3533daff |
76 | Our initial attempt at object creation will utilize the "URL |
77 | arguments" feature of Catalyst (we will employ the more common form- |
78 | based submission in the sections that follow). |
d442cc9f |
79 | |
80 | |
81 | =head2 Include a Create Action in the Books Controller |
82 | |
83 | Edit C<lib/MyApp/Controller/Books.pm> and enter the following method: |
84 | |
85 | =head2 url_create |
86 | |
87 | Create a book with the supplied title, rating, and author |
88 | |
89 | =cut |
90 | |
91 | sub url_create : Local { |
92 | # In addition to self & context, get the title, rating, & |
93 | # author_id args from the URL. Note that Catalyst automatically |
94 | # puts extra information after the "/<controller_name>/<action_name/" |
95 | # into @_ |
96 | my ($self, $c, $title, $rating, $author_id) = @_; |
97 | |
98 | # Call create() on the book model object. Pass the table |
99 | # columns/field values we want to set as hash values |
d0496197 |
100 | my $book = $c->model('DB::Books')->create({ |
d442cc9f |
101 | title => $title, |
102 | rating => $rating |
103 | }); |
104 | |
105 | # Add a record to the join table for this book, mapping to |
106 | # appropriate author |
107 | $book->add_to_book_authors({author_id => $author_id}); |
108 | # Note: Above is a shortcut for this: |
109 | # $book->create_related('book_authors', {author_id => $author_id}); |
110 | |
111 | # Assign the Book object to the stash for display in the view |
112 | $c->stash->{book} = $book; |
113 | |
114 | # This is a hack to disable XSUB processing in Data::Dumper |
115 | # (it's used in the view). This is a work-around for a bug in |
116 | # the interaction of some versions or Perl, Data::Dumper & DBIC. |
117 | # You won't need this if you aren't using Data::Dumper (or if |
118 | # you are running DBIC 0.06001 or greater), but adding it doesn't |
119 | # hurt anything either. |
120 | $Data::Dumper::Useperl = 1; |
121 | |
122 | # Set the TT template to use |
123 | $c->stash->{template} = 'books/create_done.tt2'; |
124 | } |
125 | |
126 | Notice that Catalyst takes "extra slash-separated information" from the |
127 | URL and passes it as arguments in C<@_>. The C<url_create> action then |
128 | uses a simple call to the DBIC C<create> method to add the requested |
129 | information to the database (with a separate call to |
130 | C<add_to_book_authors> to update the join table). As do virtually all |
131 | controller methods (at least the ones that directly handle user input), |
132 | it then sets the template that should handle this request. |
133 | |
134 | |
135 | =head2 Include a Template for the C<url_create> Action: |
136 | |
137 | Edit C<root/src/books/create_done.tt2> and then enter: |
138 | |
139 | [% # Use the TT Dumper plugin to Data::Dumper variables to the browser -%] |
140 | [% # Not a good idea for production use, though. :-) 'Indent=1' is -%] |
141 | [% # optional, but prevents "massive indenting" of deeply nested objects -%] |
142 | [% USE Dumper(Indent=1) -%] |
143 | |
144 | [% # Set the page title. META can 'go back' and set values in templates -%] |
145 | [% # that have been processed 'before' this template (here it's for -%] |
146 | [% # root/lib/site/html and root/lib/site/header). Note that META on -%] |
147 | [% # simple strings (e.g., no variable interpolation). -%] |
148 | [% META title = 'Book Created' %] |
149 | |
150 | [% # Output information about the record that was added. First title. -%] |
151 | <p>Added book '[% book.title %]' |
152 | |
153 | [% # Output the last name of the first author. This is complicated by an -%] |
154 | [% # issue in TT 2.15 where blessed hash objects are not handled right. -%] |
155 | [% # First, fetch 'book.authors' from the DB once. -%] |
156 | [% authors = book.authors %] |
157 | [% # Now use IF statements to test if 'authors.first' is "working". If so, -%] |
158 | [% # we use it. Otherwise we use a hack that seems to keep TT 2.15 happy. -%] |
159 | by '[% authors.first.last_name IF authors.first; |
160 | authors.list.first.value.last_name IF ! authors.first %]' |
161 | |
162 | [% # Output the rating for the book that was added -%] |
163 | with a rating of [% book.rating %].</p> |
164 | |
165 | [% # Provide a link back to the list page -%] |
166 | [% # 'uri_for()' builds a full URI; e.g., 'http://localhost:3000/books/list' -%] |
8a7c5151 |
167 | <p><a href="[% c.uri_for('/books/list') %]">Return to list</a></p> |
d442cc9f |
168 | |
169 | [% # Try out the TT Dumper (for development only!) -%] |
170 | <pre> |
171 | Dump of the 'book' variable: |
172 | [% Dumper.dump(book) %] |
173 | </pre> |
174 | |
175 | The TT C<USE> directive allows access to a variety of plugin modules (TT |
176 | plugins, that is, not Catalyst plugins) to add extra functionality to |
177 | the base TT capabilities. Here, the plugin allows L<Data::Dumper> |
178 | "pretty printing" of objects and variables. Other than that, the rest |
3533daff |
179 | of the code should be familiar from the examples in Part 3. |
d442cc9f |
180 | |
181 | B<IMPORTANT NOTE> As mentioned earlier, the C<MyApp::View::TT.pm> view |
182 | class created by TTSite redefines the name used to access the Catalyst |
183 | context object in TT templates from the usual C<c> to C<Catalyst>. |
184 | |
185 | =head2 Try the C<url_create> Feature |
186 | |
187 | If the application is still running from before, use C<Ctrl-C> to kill |
188 | it. Then restart the server: |
189 | |
190 | $ script/myapp_server.pl |
191 | |
192 | Note that new path for C</books/url_create> appears in the startup debug |
193 | output. |
194 | |
195 | B<TIP>: You can use C<script/myapp_server.pl -r> to have the development |
196 | server auto-detect changed files and reload itself (if your browser acts |
197 | odd, you should also try throwing in a C<-k>). If you make changes to |
198 | the TT templates only, you do not need to reload the development server |
199 | (only changes to "compiled code" such as Controller and Model C<.pm> |
200 | files require a reload). |
201 | |
202 | Next, use your browser to enter the following URL: |
203 | |
204 | http://localhost:3000/books/url_create/TCPIP_Illustrated_Vol-2/5/4 |
205 | |
206 | Your browser should display " Added book 'TCPIP_Illustrated_Vol-2' by |
207 | 'Stevens' with a rating of 5." along with a dump of the new book model |
208 | object. You should also see the following DBIC debug messages displayed |
3533daff |
209 | in the development server log messages if you have DBIC_TRACE set: |
d442cc9f |
210 | |
211 | INSERT INTO books (rating, title) VALUES (?, ?): `5', `TCPIP_Illustrated_Vol-2' |
212 | INSERT INTO book_authors (author_id, book_id) VALUES (?, ?): `4', `6' |
213 | SELECT author.id, author.first_name, author.last_name |
214 | FROM book_authors me JOIN authors author |
215 | ON ( author.id = me.author_id ) WHERE ( me.book_id = ? ): '6' |
216 | |
217 | The C<INSERT> statements are obviously adding the book and linking it to |
218 | the existing record for Richard Stevens. The C<SELECT> statement results |
219 | from DBIC automatically fetching the book for the C<Dumper.dump(book)>. |
220 | |
221 | If you then click the "Return to list" link, you should find that there |
222 | are now six books shown (if necessary, Shift-Reload your browser at the |
223 | C</books/list> page). |
224 | |
225 | Then I<add 2 more copies of the same book> so that we have some extras for |
226 | our delete logic that will be coming up soon. Enter the same URL above |
227 | two more times (or refresh your browser twice if it still contains this |
228 | URL): |
229 | |
230 | http://localhost:3000/books/url_create/TCPIP_Illustrated_Vol-2/5/4 |
231 | |
232 | You should be able to click "Return to list" and now see 3 copies of |
233 | "TCP_Illustrated_Vol-2". |
234 | |
235 | |
236 | =head1 MANUALLY BUILDING A CREATE FORM |
237 | |
238 | Although the C<url_create> action in the previous step does begin to |
239 | reveal the power and flexibility of both Catalyst and DBIC, it's |
240 | obviously not a very realistic example of how users should be expected |
241 | to enter data. This section begins to address that concern. |
242 | |
243 | |
244 | =head2 Add Method to Display The Form |
245 | |
246 | Edit C<lib/MyApp/Controller/Books.pm> and add the following method: |
247 | |
248 | =head2 form_create |
249 | |
250 | Display form to collect information for book to create |
251 | |
252 | =cut |
253 | |
254 | sub form_create : Local { |
255 | my ($self, $c) = @_; |
256 | |
257 | # Set the TT template to use |
258 | $c->stash->{template} = 'books/form_create.tt2'; |
259 | } |
260 | |
261 | This action simply invokes a view containing a book creation form. |
262 | |
263 | =head2 Add a Template for the Form |
264 | |
265 | Open C<root/src/books/form_create.tt2> in your editor and enter: |
266 | |
267 | [% META title = 'Manual Form Book Create' -%] |
268 | |
8a7c5151 |
269 | <form method="post" action="[% c.uri_for('form_create_do') %]"> |
d442cc9f |
270 | <table> |
271 | <tr><td>Title:</td><td><input type="text" name="title"></td></tr> |
272 | <tr><td>Rating:</td><td><input type="text" name="rating"></td></tr> |
273 | <tr><td>Author ID:</td><td><input type="text" name="author_id"></td></tr> |
274 | </table> |
275 | <input type="submit" name="Submit" value="Submit"> |
276 | </form> |
277 | |
278 | Note that we have specified the target of the form data as |
279 | C<form_create_do>, the method created in the section that follows. |
280 | |
281 | =head2 Add a Method to Process Form Values and Update Database |
282 | |
283 | Edit C<lib/MyApp/Controller/Books.pm> and add the following method to |
284 | save the form information to the database: |
285 | |
286 | =head2 form_create_do |
287 | |
288 | Take information from form and add to database |
289 | |
290 | =cut |
291 | |
292 | sub form_create_do : Local { |
293 | my ($self, $c) = @_; |
294 | |
295 | # Retrieve the values from the form |
296 | my $title = $c->request->params->{title} || 'N/A'; |
297 | my $rating = $c->request->params->{rating} || 'N/A'; |
298 | my $author_id = $c->request->params->{author_id} || '1'; |
299 | |
300 | # Create the book |
d0496197 |
301 | my $book = $c->model('DB::Books')->create({ |
d442cc9f |
302 | title => $title, |
303 | rating => $rating, |
304 | }); |
305 | # Handle relationship with author |
306 | $book->add_to_book_authors({author_id => $author_id}); |
307 | |
308 | # Store new model object in stash |
309 | $c->stash->{book} = $book; |
310 | |
311 | # Avoid Data::Dumper issue mentioned earlier |
312 | # You can probably omit this |
313 | $Data::Dumper::Useperl = 1; |
314 | |
315 | # Set the TT template to use |
316 | $c->stash->{template} = 'books/create_done.tt2'; |
317 | } |
318 | |
319 | |
320 | =head2 Test Out The Form |
321 | |
322 | If the application is still running from before, use C<Ctrl-C> to kill |
323 | it. Then restart the server: |
324 | |
325 | $ script/myapp_server.pl |
326 | |
327 | Point your browser to L<http://localhost:3000/books/form_create> and |
328 | enter "TCP/IP Illustrated, Vol 3" for the title, a rating of 5, and an |
329 | author ID of 4. You should then be forwarded to the same |
330 | C<create_done.tt2> template seen in earlier examples. Finally, click |
331 | "Return to list" to view the full list of books. |
332 | |
333 | B<Note:> Having the user enter the primary key ID for the author is |
334 | obviously crude; we will address this concern with a drop-down list in |
3533daff |
335 | Part 9. |
d442cc9f |
336 | |
337 | |
338 | =head1 A SIMPLE DELETE FEATURE |
339 | |
340 | Turning our attention to the delete portion of CRUD, this section |
341 | illustrates some basic techniques that can be used to remove information |
342 | from the database. |
343 | |
344 | |
345 | =head2 Include a Delete Link in the List |
346 | |
347 | Edit C<root/src/books/list.tt2> and update it to the following (two |
348 | sections have changed: 1) the additional '<th>Links</th>' table header, |
349 | and 2) the four lines for the Delete link near the bottom). |
350 | |
351 | [% # This is a TT comment. The '-' at the end "chomps" the newline. You won't -%] |
352 | [% # see this "chomping" in your browser because HTML ignores blank lines, but -%] |
353 | [% # it WILL eliminate a blank line if you view the HTML source. It's purely -%] |
354 | [%- # optional, but both the beginning and the ending TT tags support chomping. -%] |
355 | |
356 | [% # Provide a title to root/lib/site/header -%] |
357 | [% META title = 'Book List' -%] |
358 | |
359 | <table> |
360 | <tr><th>Title</th><th>Rating</th><th>Author(s)</th><th>Links</th></tr> |
361 | [% # Display each book in a table row %] |
362 | [% FOREACH book IN books -%] |
363 | <tr> |
364 | <td>[% book.title %]</td> |
365 | <td>[% book.rating %]</td> |
366 | <td> |
367 | [% # First initialize a TT variable to hold a list. Then use a TT FOREACH -%] |
368 | [% # loop in 'side effect notation' to load just the last names of the -%] |
369 | [% # authors into the list. Note that the 'push' TT vmethod does not -%] |
370 | [% # a value, so nothing will be printed here. But, if you have something -%] |
371 | [% # in TT that does return a method and you don't want it printed, you -%] |
372 | [% # can: 1) assign it to a bogus value, or 2) use the CALL keyword to -%] |
373 | [% # call it and discard the return value. -%] |
374 | [% tt_authors = [ ]; |
375 | tt_authors.push(author.last_name) FOREACH author = book.authors %] |
376 | [% # Now use a TT 'virtual method' to display the author count in parens -%] |
377 | ([% tt_authors.size %]) |
378 | [% # Use another TT vmethod to join & print the names & comma separators -%] |
379 | [% tt_authors.join(', ') %] |
380 | </td> |
381 | <td> |
382 | [% # Add a link to delete a book %] |
8a7c5151 |
383 | <a href="[% c.uri_for('delete', book.id) %]">Delete</a> |
d442cc9f |
384 | </td> |
385 | </tr> |
386 | [% END -%] |
387 | </table> |
388 | |
389 | The additional code is obviously designed to add a new column to the |
390 | right side of the table with a C<Delete> "button" (for simplicity, links |
391 | will be used instead of full HTML buttons). |
392 | |
393 | =head2 Add a Delete Action to the Controller |
394 | |
395 | Open C<lib/MyApp/Controller/Books.pm> in your editor and add the |
396 | following method: |
397 | |
398 | =head2 delete |
399 | |
400 | Delete a book |
401 | |
402 | =cut |
403 | |
404 | sub delete : Local { |
405 | # $id = primary key of book to delete |
406 | my ($self, $c, $id) = @_; |
407 | |
408 | # Search for the book and then delete it |
d0496197 |
409 | $c->model('DB::Books')->search({id => $id})->delete_all; |
d442cc9f |
410 | |
411 | # Set a status message to be displayed at the top of the view |
412 | $c->stash->{status_msg} = "Book deleted."; |
413 | |
414 | # Forward to the list action/method in this controller |
415 | $c->forward('list'); |
416 | } |
417 | |
418 | This method first deletes the book with the specified primary key ID. |
419 | However, it also removes the corresponding entry from the |
420 | C<book_authors> table. Note that C<delete_all> was used instead of |
421 | C<delete>: whereas C<delete_all> also removes the join table entries in |
422 | C<book_authors>, C<delete> does not (only use C<delete_all> if you |
423 | really need the cascading deletes... otherwise you are wasting resources). |
424 | |
425 | Then, rather than forwarding to a "delete done" page as we did with the |
426 | earlier create example, it simply sets the C<status_msg> to display a |
427 | notification to the user as the normal list view is rendered. |
428 | |
429 | The C<delete> action uses the context C<forward> method to return the |
430 | user to the book list. The C<detach> method could have also been used. |
431 | Whereas C<forward> I<returns> to the original action once it is |
432 | completed, C<detach> does I<not> return. Other than that, the two are |
433 | equivalent. |
434 | |
435 | |
436 | =head2 Try the Delete Feature |
437 | |
438 | If the application is still running from before, use C<Ctrl-C> to kill |
439 | it. Then restart the server: |
440 | |
441 | $ script/myapp_server.pl |
442 | |
443 | Then point your browser to L<http://localhost:3000/books/list> and click |
444 | the "Delete" link next to the first "TCPIP_Illustrated_Vol-2". A green |
445 | "Book deleted" status message should display at the top of the page, |
446 | along with a list of the eight remaining books. |
447 | |
448 | |
449 | =head2 Fixing a Dangerous URL |
450 | |
5edc2aae |
451 | Note the URL in your browser once you have performed the deletion in the |
d442cc9f |
452 | prior step -- it is still referencing the delete action: |
453 | |
454 | http://localhost:3000/books/delete/6 |
455 | |
456 | What if the user were to press reload with this URL still active? In |
457 | this case the redundant delete is harmless, but in other cases this |
458 | could clearly be extremely dangerous. |
459 | |
460 | We can improve the logic by converting to a redirect. Unlike |
461 | C<$c-E<gt>forward('list'))> or C<$c-E<gt>detach('list'))> that perform |
462 | a server-side alteration in the flow of processing, a redirect is a |
3533daff |
463 | client-side mechanism that causes the browser to issue an entirely |
d442cc9f |
464 | new request. As a result, the URL in the browser is updated to match |
465 | the destination of the redirection URL. |
466 | |
467 | To convert the forward used in the previous section to a redirect, |
468 | open C<lib/MyApp/Controller/Books.pm> and edit the existing |
469 | C<sub delete> method to match: |
470 | |
471 | =head2 delete |
472 | |
473 | Delete a book |
474 | |
475 | =cut |
476 | |
477 | sub delete : Local { |
478 | # $id = primary key of book to delete |
479 | my ($self, $c, $id) = @_; |
480 | |
481 | # Search for the book and then delete it |
d0496197 |
482 | $c->model('DB::Books')->search({id => $id})->delete_all; |
d442cc9f |
483 | |
484 | # Set a status message to be displayed at the top of the view |
485 | $c->stash->{status_msg} = "Book deleted."; |
486 | |
487 | # Redirect the user back to the list page |
488 | $c->response->redirect($c->uri_for('/books/list')); |
489 | } |
490 | |
491 | |
492 | =head2 Try the Delete and Redirect Logic |
493 | |
494 | Restart the development server and point your browser to |
3533daff |
495 | L<http://localhost:3000/books/list> and delete the first copy of |
496 | "TCPIP_Illustrated_Vol-2". The URL in your browser should return to |
497 | the L<http://localhost:3000/books/list> URL, so that is an |
498 | improvement, but notice that I<no green "Book deleted" status message |
499 | is displayed>. Because the stash is reset on every request (and a |
500 | redirect involves a second request), the C<status_msg> is cleared |
501 | before it can be displayed. |
d442cc9f |
502 | |
503 | |
504 | =head2 Using C<uri_for> to Pass Query Parameters |
505 | |
506 | There are several ways to pass information across a redirect. |
507 | In general, the best option is to use the C<flash> technique that we |
3533daff |
508 | will see in Part 5 of the tutorial; however, here we will pass the |
d442cc9f |
509 | information via query parameters on the redirect itself. Open |
510 | C<lib/MyApp/Controller/Books.pm> and update the existing |
511 | C<sub delete> method to match the following: |
512 | |
513 | =head2 delete |
514 | |
515 | Delete a book |
516 | |
517 | =cut |
518 | |
519 | sub delete : Local { |
520 | # $id = primary key of book to delete |
521 | my ($self, $c, $id) = @_; |
522 | |
523 | # Search for the book and then delete it |
d0496197 |
524 | $c->model('DB::Books')->search({id => $id})->delete_all; |
d442cc9f |
525 | |
526 | # Redirect the user back to the list page with status msg as an arg |
527 | $c->response->redirect($c->uri_for('/books/list', |
528 | {status_msg => "Book deleted."})); |
529 | } |
530 | |
531 | This modification simply leverages the ability of C<uri_for> to include |
532 | an arbitrary number of name/value pairs in a hash reference. Next, we |
533 | need to update C<root/lib/site/layout> to handle C<status_msg> as a |
534 | query parameter: |
535 | |
536 | <div id="header">[% PROCESS site/header %]</div> |
537 | |
538 | <div id="content"> |
8a7c5151 |
539 | <span class="message">[% status_msg || c.request.params.status_msg %]</span> |
d442cc9f |
540 | <span class="error">[% error_msg %]</span> |
541 | [% content %] |
542 | </div> |
543 | |
544 | <div id="footer">[% PROCESS site/footer %]</div> |
545 | |
546 | |
547 | =head2 Try the Delete and Redirect With Query Param Logic |
548 | |
549 | Restart the development server and point your browser to |
550 | L<http://localhost:3000/books/list>. Then delete the remaining copy |
551 | of "TCPIP_Illustrated_Vol-2". The green "Book deleted" status message |
552 | should return. |
553 | |
554 | B<NOTE:> Although this did present an opportunity to show a handy |
555 | capability of C<uri_for>, it would be much better to use Catalyst's |
556 | C<flash> feature in this situation. Although the technique here is |
557 | less dangerous than leaving the delete URL in the client's browser, |
558 | we have still exposed the status message to the user. With C<flash>, |
559 | this message returns to its rightful place as a service-side |
560 | mechanism (we will migrate this code to C<flash> in the next part |
561 | of the tutorial). |
562 | |
563 | |
564 | =head1 AUTHOR |
565 | |
566 | Kennedy Clark, C<hkclark@gmail.com> |
567 | |
568 | Please report any errors, issues or suggestions to the author. The |
569 | most recent version of the Catalyst Tutorial can be found at |
d712b826 |
570 | L<http://dev.catalyst.perl.org/repos/Catalyst/trunk/Catalyst-Manual/lib/Catalyst/Manual/Tutorial/>. |
d442cc9f |
571 | |
45c7830f |
572 | Copyright 2006-2008, Kennedy Clark, under Creative Commons License |
d442cc9f |
573 | (L<http://creativecommons.org/licenses/by-nc-sa/2.5/>). |
574 | |