Skip to content
Newer
Older
100644 395 lines (256 sloc) 20.5 KB
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
1 ///TITLE///
2 Modern Perl: Adding to Our Web Application
3
4 ///STANDFIRST///
a9f14f3 @davorg Version as sent to LXF.
authored Dec 7, 2011
5 Part 3: The power of web frameworks is how they take care of the standard features you need. In this article Dave Cross uses Dancer to add interactivity to his reading list program.
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
6
7 ///ON THE DVD LOGO///
8
9 ///OUR EXPERT BOX///
10 Dave Cross has been involved with the Perl community since the last millennium. In 1998 he started the London Perl Mongers, the first European Perl users group.
11 ///END OUR EXPERT BOX///
12
e1d163a @davorg Added quick tips.
authored Dec 6, 2011
13 ///QUICK TIP///
14 The best book about Perl is called "Programming Perl". The fourth edition has just been published.
15 ///END QUICK TIP///
16
17 ///QUICK TIP///
18 There are a huge number of blogs dedicated to Perl programming. Many of the best ones are collected at http://mgnm.at/ironman.
19 ///END QUICK TIP///
20
21 ///QUICK TIP///
22 New major versions of Perl are released annually. The current version is 5.14. Version 5.16 will be released in the spring.
23 ///END QUICK TIP///
24
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
25 ///BODY COPY///
26
27 In our previous article, we added a web front end to our reading list program. But this interface only displayed the contents of our database, we still needed to use the command line program to change the data. In this article, we'll fix that by adding interactivity to our web application. By the end of this article you won't need the command line program at all.
28
29 This will involve two major changes to the web app. Firstly we'll add actions to deal with adding books to the reading list and starting and finishing books. But if you want to put your reading list on a public web site, you don't want just anyone to be able to edit it so we'll also implement a basic level of authorisation and authentication.
30
31 As in the previous article, we'll find that our framework of choice, Dancer, will make this all a lot easier than it would be doing it all from scratch.
32
33 ///CROSSHEAD///
34 How to read a book
35
36 We'll start by adding routes to our application allowing us to start and finish reading books. We'll do this before adding books to the list as this actions are simpler. We'll implement these actions by adding new route definitions to the BookWeb.pm file. Here's the defintion of the start route.
37
38 ///CODE///
39
40 get '/start/:isbn' => sub {
41
42 my $books_rs = schema->resultset('Book');
43 my $book = $books_rs->find({ isbn => param('isbn')});
44
45 if ($book) {
46 $book->update({started => DateTime->now});
47 }
48
49 return redirect '/';
50 };
51
52 ///END CODE///
53
54 Like all Dancer routes, this definition consists of an HTTP action (in this case 'get') a path and some code to execute when the first two items are matched. The path here is more complex than the path that we saw last time as it contains a parameter. The URL that we want to use to start reading a book looks like http://example.com/start/1930110006. This will flag that you have started reading the book with ISBN 1930110006.
55
56 Obviously, that ISBN value will change for different books, so we need a way to capture that parameter and use it in our code. In a Dancer route, you can match parameters with the ':name' syntax that you see in our definition. You can have more than one parameter defined in the route as long as they are named and separated by slashes. You access these parameters using Dancer's 'param' function.
57
58 The rest of the code will look familiar to anyone who read the first article in this series (LXF 151) where we wrote the command line version of this program. We get a resultset for our book table, search it for a book with the given ISBN and then update the 'started' column in that object to be equal to the current date and time. You might also remember that the DBIx::Class tool that we are using for database access automatically converts between Perl DateTime objects and date/time columns in your database.
59
60 Notice that if we don't find a book with the given ISBN, then we do nothing. It might be worth displaying an error message at that point. Or perhaps, redirecting to the 'add' action (which we haven't written yet).
61
62 Once we have updated the book record, we just use Dancer's 'redirect' function to redirect the browser back to the main page of the application. The user will then see the chosen book has moved from the 'To Read' list to the 'Reading' list.
63
64 The code for the 'end' route is almost identical. Only the path and the database column will differ. The path will be '/end/:isbn' and we'll need to update the 'ended' column in the database.
65
a9f14f3 @davorg Version as sent to LXF.
authored Dec 7, 2011
66 ///CROSSHEAD///
67 Adding new books
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
68
69 The next thing we need to do is to add new books to the list. Again, we'll be repurposing code from the original command line program. As we need to go to Amazon for details of the book, we need to create a Net::Amazon object. We'll need this object in a couple of places, so we'll write a 'get_amazon()' subroutine that creates the object for us.
70
71 ///CODE///
72
73 sub get_amazon {
74 return Net::Amazon->new(
75 token => $ENV{AMAZON_KEY},
76 secret_key => $ENV{AMAZON_SECRET},
77 associate_tag => $ENV{AMAZON_ASSTAG},
78 locale => 'uk',
79 ) or die "Cannot connect to Amazon\n";
80 }
81
82 ///END CODE///
83
84 There's nothing complicated here. It's just calling the constructor on the Net::Amazon class and returning the object that is created. Annoyingly, Amazon have changed the way that this works since I wrote the first article in this series. See the boxout "Amazon API Changes" for more details.
85
86 We can now define our add route. The path will be a similar format to the start and end routes. The code looks like this:
87
88 ///CODE///
89
90 get '/add/:isbn' => sub {
91 my $author_rs = schema->resultset('Author');
92
93 my $amz = get_amazon();
94
95 # Search for the book at Amazon
96 my $resp = $amz->search(asin => param('isbn'));
97
98 unless ($resp->is_success) {
99 die 'Error: ', $resp->message;
100 }
101
102 my $book = $resp->properties;
103 my $title = $book->ProductName;
104 my $author_name = ($book->authors)[0];
105 my $imgurl = $book->ImageUrlMedium;
106
107 # Find or create the author
108 my $author = $author_rs->find_or_create({
109 name => $author_name,
110 });
111
112 # Add the book to the author
113 $author->add_to_books({
114 isbn => param('isbn'),
115 title => $title,
116 image_url => $imgurl,
117 });
118
119 return redirect '/';
120 };
121
122 ///END CODE///
123
124 In this function we need to talk to both the database and Amazon, so the first thing we do is to create an author resultset and a Net::Amazon object. We then search Amazon for the ISBN that we have been given. If we find it, we first create an author record (or find the existing one if we already know about this author) and then insert details of the book. Once again, when we have finished, we just need to redirect to the front page and the user will see their new book in the 'to read' list.
125
126 ///CROSSHEAD///
127 Adding links
128
129 That's all very well, but currently the only way to access our new routes is by typing addresses including the ISBNs into the location bar in your browser. That's hardly user-friendly. Let's fix that by adding links to the list of books. In the file views/index.tt we have a macro called 'showbook' which is responsible for displaying an individual book in the main list. We can edit that and have the links appear for every book. Once the links have been added, the macro looks like this:
130
131 ///CODE///
132
133 <% MACRO showbook(book) BLOCK %>
134 <div class="book"><p><img src="<% book.image_url %>" />
135 <a href="http://amazon.co.uk/dp/<% book.isbn %>"><% book.title %></a>
136 <br />By <% book.author.name %></p>
137 <p><% IF book.started %>Began reading: <% book.started.strftime('%d %b %Y') %>.<% END %>
138 <% IF book.ended %>Finished reading: <% book.ended.strftime('%d %b %Y') %>.<% END %></p>
139 <% IF book.started AND NOT book.ended -%>
140 <p><a href="/end/<% book.isbn %>">Finish book</a></p>
141 <% ELSIF NOT book.started -%>
142 <p><a href="/start/<% book.isbn %>">Start book</a></p>
143 <% END %>
144 </div>
145 <% END %>
146
147 ///END CODE///
148
149 Our additions are towards the end. If the book has a value in the start date but no value in the end date then it must be in the 'reading' list and we display a 'finish book' link. If it has no start date then it must be in the 'to read' list and we display a 'start book' link.
150
151 If we make these changes and start our application (with 'bin/app.pl'), you should see these links appearing next to the books - assuming that you have books on the list. And that brings us neatly to the next problem. We need a better way to add books to the list. Let's do it by searching Amazon.
152
e1d163a @davorg Added quick tips.
authored Dec 6, 2011
153 ///PIC///
154 hunger_links.png
155
156 ///CAPTION///
157 The books application with links allowing you maintain your reading list.
158
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
159 ///CROSSHEAD///
160 Amazon Exploration
161
162 The best place for a search box is in a sidebar that appears on every page. Our sidebar is defined in views/layouts/main.tt. Edit the sidebar div so it looks like this:
163
164 ///CODE///
165
166 <div id="sidebar">
167 <p><form method="POST" action="/search"><p>Search Amazon:
168 <input name="search" values="<% search %>" /> <input type="submit" value="Search" /></form></p>
169 </div>
170
171 ///END CODE///
172
173 That will put a search box on every page in our application. But now we need to write code to carry our the search and display the results. Notice in the form definition we have said that the form sends a POST request to '/search'. That gives us a couple of clues as to how our route definition should look.
174
175 ///CODE///
176
177 post '/search' => sub {
178 my $amz = get_amazon();
179
180 my $resp = $amz->search(
181 keyword => param('search'),
182 mode => 'books',
183 );
184
185 my %data;
186 $data{search} = param('search');
187 if ($resp->is_success) {
188 $data{books} = [ $resp->properties ];
189 } else {
190 $data{error} = $resp->message;
191 }
192
193 template 'results', \%data;
194 };
195
196 ///END CODE///
197
198 We need a Net::Amazon object in order to search Amazon, so we get that first. We can then use the same 'search' method as we used before, but with different arguments. We tell Amazon that we're looking for a book and that the keyword we're looking for is the search term that the user has given us. If the search is successful then the books that match are retrieved by calling the 'properties' method on the response object. We put that list in a hash called %data along with the text the we searched for and pass that to the results template.
199
200 Which means we need to create a template called views/results.tt. It looks like this:
201
202 ///CODE///
203
204 <h1>BookWeb - Search Results</h1>
205 <% IF error -%>
206 <p class="error"><% error %>
207 <% ELSE %>
208 <p>You searched for: <b><% search %></b></p>
209 <% IF books.size %>
210 <ul>
211 <% FOREACH book IN books -%>
212 <li><b><% book.title %></b> (<% book.authors.list.0 %>) <a href="/add/<% book.isbn %>">Add to list</a></li>
213 <% END %>
214 </ul>
215 <% ELSE %>
216 <p>Your search returned no results.</p>
217 <% END %>
218 <% END %>
219
220 ///END CODE///
221
222 There's a bit of code there for displaying an error if the search failed and for displaying a "no results" message, but most of the code is used to display a list of books that are returned from Amazon. For each book in the list we display the title, the author and a link to add the book to our reading list.
223
e1d163a @davorg Added quick tips.
authored Dec 6, 2011
224 ///PIC///
225 search.png
226
227 ///CAPTION///
228 The search results page. Amazon seems to have a rather liberal definition of "perl".
229
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
230 If you save these changes and restart the application, you should find that you have a fully function web site that now allows you to do anything that our original command line program did. You can add new books to the list and tell the system when you start and finish a book. The only problem is that anyone else can do all of that too.
231
232 ///CROSSHEAD///
233 Adding security
234
235 Presumably you'd like to display your reading list to anyone who is interested, but you'd prefer it if only you can update it. For that we need to introduce some security. We're going use some really basic authentication, but I hope it will be obvious how to extend it for use in the real world.
236
237 We're going to add the concept of a logged in user. And we're going to store whether the current user is logged in or logged out using a session cookie. Support for sessions comes as a part of the standard Dancer distribution, but in order to store your session in a cookie, you will need to install the extra Dancer::Session::Cookie module from CPAN. Having installed the module, you need to configure it by adding the following two lines to your config.yml file:
238
239 ///CODE///
240
241 session: cookie
242 session_cookie_key: somerandomnonsense
243
244 ///END CODE///
245
246 The value of the cookie key can be any random string. The more random the better. Mine probably isn't a great example.
247
248 In order to add session support we need to add 'use Dancer::Session' to the list of modules near the top of BookWeb.pm.
249
250 Now we need to think about how our security will work. I'm going to define a list of paths that are public. Anyone can see those pages, but anyone trying to access pages outside of this list will be prompted to log in if they haven't already.
251
252 Dancer has the concept of a 'before' hook which is fired before any route is run. That's a perfect place to check whether the user is allowed to do whatever they are trying to do.
253
254 ///CODE///
255
256 my %public_path = map { $_ => 1 } ('/', '/login', '/search');
257
258 hook before => sub {
259 if (! session('logged_in') and
260 ! $public_path{request->path_info}) {
261 var requested_path => request->path_info;
262 request->path_info('/login');
263 }
264 };
265
266 ///END CODE///
267
268 The first line of this code defines a hash called %public_path. The keys of the hash are the public paths and the associated values are all 1. This makes it easy to check whether a path is public or not.
269
270 The rest of the code snippet defines the before hook. We check the session to see if the user is logged in and, if they aren't, whether they are allowed to see the page that they are trying to visit. The path they have requested is given by the current request's path_info method. We store that original path in a temporary scratch variable called 'requested_path' and overwrite the requested path with a request to the login page. Which we now need to write. Logging in is handled with two routes like this:
271
272 ///CODE///
273
274 get '/login' => sub {
275 template 'login', { path => vars->{requested_path } };
276 };
277
278 post '/login' => sub {
279 if (params->{user} eq 'reader' && params->{pass} eq 'letmein') {
280 session 'logged_in' => 1;
281 }
282
283 redirect params->{path} || '/';
284 };
285
286 ///END CODE///
287
a9f14f3 @davorg Version as sent to LXF.
authored Dec 7, 2011
288 This is a nice illustration of the power of Dancer routes. We have two routes with the same path but with different HTTP request types. If we make a GET request to /login then the first route is triggered. If we make a POST request to the same path then the second route fires. The before hook makes a GET request, so the first route runs. That displays the login template, passing it the original requested path. Here's that template, which lives in views/login.tt:
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
289
290 ///CODE///
291
292 <div id="header">
293 <h1>BookWeb</h1>
294 <h2>Login</h2>
295 </div>
296 <p>You need to be logged in to do that</p>
297 <form method="POST" action="/login">
298 <p>User: <input name="user" /><br />
299 Password: <input type="password" name="pass" /><br />
300 <input type="submit" value="Log in" />
301 <input type="hidden" name="path" value="<% path %>" />
302 </form>
303
304 ///END CODE///
305
a9f14f3 @davorg Version as sent to LXF.
authored Dec 7, 2011
306 That's all standard stuff. Notice that we've stored the original path in a hidden input on the form. The important thing is that the form method is POST, which means that when it is submitted the second login route is triggered. That checks that the username and password are correct and if they are it redirects the user to the path that they originally requested. Of course, in a real application you wouldn't have the username and password hard-coded into your program.
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
307
e1d163a @davorg Added quick tips.
authored Dec 6, 2011
308 ///PIC///
309 login.png
310
311 ///CAPTION///
312 The login page looks rather basic, but it gets the job done.
313
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
314 There's just one more thing to add. If you can log in to an application then it's nice to be able to log out. That's handled with a really simple route.
315
316 ///CODE///
317
318 get '/logout' => sub {
319 session 'logged_in' => 0;
320
321 redirect '/';
322 };
323
324 ///END CODE///
325
326 All this does is to set the logged in flag to false and then redirects the user back to the main page. It's nice to give the user a link to log out so I've added the following code to the sidebar in views/layouts/main.tt.
327
328 ///CODE///
329
330 <% IF logged %><a href="/logout">Log out</a>
331 <% ELSE %><a href="/login">Log in</a><% END %>
332
333 ///END CODE///
334
335 That has two purposes. If the user is not logged in, then it displays a login link. And when the user is logged in it displays a log out link.
336
a9f14f3 @davorg Version as sent to LXF.
authored Dec 7, 2011
337 And that is our application complete. If you run the app one last time you'll see that anyone can see your reading list but that if you try to do anything to change the contents of the list you are asked to log in. There are, of course, many other improvements that can be made to the application. I make some suggestions in the boxout on this page.
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
338
339 I hope you find this application useful. And I hope that you see how the Modern Perl tools that you can find on CPAN make it easy to write really quite complex applications.
340
341 ///END BODY TEXT///
342
343 ///COMPULSORY BOX///
344
345 ///BOX TITLE///
346 Amazon API Changes
347
348 ///BOX BODY///
349
350 It's rare for a big company like Amazon to make changes to their web services API in such a way that it breaks a lot of existing code. But, unfortunately, that's exactly what happened at some point after I wrote the previous article in this series. In the older version of the API you needed a key and a secret. These values were passed to Net::Amazon as you created the object. Amazon have now added a third mandatory parameter which is your Amazon associates ID. Like the other two parameters, you can get this value from your Amazon web services account information.
351
352 The Net::Amazon module checks that you have given it all of the mandatory parameters when you call its constructor method. Older versions of this module checked for the key and the secret. But once the API change was introduced those parameters weren't enough and any API calls were failing with an error about the missing parameter. Version 0.61 of Net::Amazon adds the associates ID to the list of mandatory parameters that the constructor requires. The new version of the call is shown in the code in this article. I recommend that you update your version of Net::Amazon to avoid any potential problems.
353
354 ///END BOX///
355
356 ///OPTIONAL BOX///
357
358 ///BOX TITLE///
359 Deploying your application
360
361 ///BOX TEXT///
362
363 Throughout the last couple of articles, we've been using Dancer's built-in test web server to run our web application. But if you find the app to be useful you'll eventually want to deploy it on a real, public web server. How simple is that?
364
365 It's actually very simple. And there are a number of different options available. Dancer is build on top of Perl technology called "PSGI" which is a protocol that defines the interactions between a web application and the web hosting environment where the application runs. The beauty of this approach is that if you have a PSGI-compatible application then it's simple enough to deploy it in any PSGI-ready web hosting environment. And as any Dancer application is already PSGI compatible, you can deploy it just about anywhere.
366
367 Details of some common deployment scenarios are in the Dancer::Deployment manual page which comes as part of the standard Dancer distribution. Just enter "perldoc Dancer::Deployment" at your command line to read it. For more details of PSGI (and Plack which is a reference implementation of the specification) see the project's web site at http://plackperl.org/.
368
369 ///END BOX///
370
371 ///COMPULSORY BOX///
372
373 ///BOX TITLE///
374 Further suggestions
375
376 ///BOX TEXT///
377
378 Over the course of these three articles, we have created the skeleton of a useful little application. But there are a number of improvements that can be made. Here are a few suggestions.
379
380 * The HTML and CSS I've used have been very basic. They can be improved to make the application look more attractive.
381
1e6b401 @davorg Added another suggestion.
authored Dec 5, 2011
382 * Currently we show the maintenance links to everyone and only authenticate when someone tries to use them. An alternative approach would be to only display the links if the user is logged in.
383
7445997 @davorg Article for LXF155.
authored Dec 5, 2011
384 * Currently all of the list maintenance actions are full requests to the server. It would be possible to rewrite them to use AJAX so that the user experience is smoother.
385
386 * The database contains all the data required to create a web feed of your current reading matter. Perhaps a route at '/feed' which displayed an Atom feed of all books that have been started or finished in the last 24 hours.
387
388 * It would be possible to add to the '/start' and '/end' actions so that they tell Twitter what they are doing. "Dave has just started reading ..." or something like that.
389
390 * If you moved the username and password information into the database then it wouldn't be too hard to make it into a multi-user system.
391
392 If you implement any of these suggestions, or come up with any other improvements I'd be very interested to hear about them.
393
1e6b401 @davorg Added another suggestion.
authored Dec 5, 2011
394 ///END BOX///
Something went wrong with that request. Please try again.