Day 17: API
Previously on symfony
The askeet application was just put online yesterday, and we already have a lot of feedback about feature tweaking and additions. The user input is fundamental to the design of a web 2.0 application, and even if the concept of the application is new, it has to be experimented with as soon as possible.
But we will add unplanned functionalities on day 21. Before that, we have scheduled a handful of advanced web application development techniques to show you through askeet, and the first to be revealed today is the programming of an external API requiring an HTTP authentication.
As we made quite a lot of little changes yesterday, you are strongly advised to start today's tutorial with a fresh downloaded version of askeet from the day 16 tagged version in the askeet repository.
The API
An Application Programming Interface, or API, is a developer's interface to a particular service on your application, so that it can be included in external websites. Think about Google Maps or Flickr, which are used to extend lots of websites over the Internet thanks to their APIs.
Askeet makes no exception, and we believe that in order to increase the service's popularity, it has to be made available to other websites. The RSS feed developed during day 11 was a first approach to that requirement, but we can do much better.
Askeet will provide an API of answers to a question asked by the user. The access to this API will be restricted to registered askeet users, through HTTP authentication. The API response format chosen is Representational State Transfer, or REST - that means that the response is a simple XML block similar to most of the output of main APIs in the web:
<?xml version="1.0" encoding="utf-8" ?> <rsp stat="ok" version="1.0"> <question href="http://www.askeet.com/question/what-shall-i-do-tonight-with-my-girlfriend" time="2005-11-21T21:19:18Z" > <title>What shall I do tonight with my girlfriend?</title> <tags> <tag>activities</tag> <tag>relatives</tag> <tag>girl</tag> <tags> <answers> <answer relevancy="50" time="2005-11-22T12:21:53Z">You can try to read her poetry. Chicks love that kind of things.</answer> <answer relevancy="0" time="2005-11-22T15:45:03Z">Don't bring her to a doughnuts shop. Ever. Girls don't like to be seen eating with their fingers - although it's nice.</answer> </answers> </question> </rsp>
We will implement the API in a new module of the frontend
application, so use the command line to build the module skeleton:
$ symfony init-module frontend api
HTTP Authentication
We choose to limit the use of the API to registered askeet users. For that, we will use the HTTP authentication process, which is a built-in authentication mechanism of the HTTP protocol. It is different from the web authentication that we have seen previously because it doesn't even require a web page - all the exchanges take place in the HTTP headers.
We will need the authentication method included in a custom validator during day six, so first of all we will do some refactoring and relocate the login code in the UserPeer
model class:
public static function getAuthenticatedUser($login, $password) { $c = new Criteria(); $c->add(UserPeer::NICKNAME, $login); $user = UserPeer::doSelectOne($c); // nickname exists? if ($user) { // password is OK? if (sha1($user->getSalt().$password) == $user->getSha1Password()) { return $user; } } return null; }
The new class method UserPeer::getAutenticatedUser()
can now be used in the myLoginValidator.class.php
(we'll leave that to you) and in the new api/index
web service:
<?php class apiActions extends sfActions { public function preExecute() { sfConfig::set('sf_web_debug', false); } public function executeIndex() { $user = $this->authenticateUser(); if (!$user) { $this->error_code = 1; $this->error_message = 'login failed'; $this->forward('api', 'error'); } // do some stuff } private function authenticateUser() { if (isset($_SERVER['PHP_AUTH_USER'])) { if ($user = UserPeer::getAuthenticatedUser($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) { $this->getContext()->getUser()->signIn($user); return $user; } } header('WWW-Authenticate: Basic realm="askeet API"'); header('HTTP/1.0 401 Unauthorized'); } public function executeError() { } } ?>
First of all, before executing any action of the API module (thus in the preExecute()
method), we turn off the web debug toolbar. The view of this action being XML, the insertion of the toolbar code would produce a non-valid response.
The first thing that the index
action will do is to check whether a login and a password are provided, and if they match an existing askeet account. If that is not the case, the authenticateUser()
method sets the response HTTP header to '401'. It will cause an HTTP authentication window to pop-up in the user's browser; the user will have to resubmit the request with the login and password.
// first request to the API, without authentication GET /api/index HTTP/1.1 Host: mysite.example.com User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; fr; rv:1.8) Gecko/20051111 Firefox/1.5 Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5 ... // the API returns a 401 header with no content HTTP/1.x 401 Authorization Required Date: Thu, 15 Dec 2005 10:32:44 GMT Server: Apache WWW-Authenticate: Basic realm="Order Answers Feed" Content-Length: 401 Keep-Alive: timeout=15, max=100 Connection: Keep-Alive Content-Type: text/html; charset=iso-8859-1 // a login box will then appear on the user's window. // Once the user enters his login/password, a new GET is sent to the server GET /api/index HTTP/1.1 Host: mysite.example.com User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; fr; rv:1.8) Gecko/20051111 Firefox/1.5 Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5 ... Authorization: Basic ZmFicG90OnN5bWZvbnk=
An Authorization
attribute is added to the HTTP request, which is sent again. It contains a base 64 encoded 'login:password' string. This is what the $_SERVER['PHP_AUTH_USER']
and $_SERVER['PHP_AUTH_PW']
look for in our authenticateUser()
method.
note
Base64 does not output an encrypted version of its input. Decoding a base64-encoded string is very easy, and it reveals the password in clear. For instance, decoding the string ZmFicG90OnN5bWZvbnk=
gives fabpot:symfony
. So you have to consider that the password transits in clear in the Internet (as when entered in a web form) and can be intercepted. HTTP authentication must be restricted to non-critical content and services for this reason. Added protection could be gained by requiring the HTTPS protocol for API calls as well.
If a login and password are provided and exist in the user database, then the index
action executes. Otherwise, it forwards to the error
action (empty) and displays the errorSuccess.php
template:
<?php echo '<?' ?>xml version="1.0" encoding="utf-8" ?> <rsp stat="fail" version="1.0"> <err code="<?php echo $error_code ?>" msg="<?php echo $error_message ?>" /> </rsp>
Of course, you have to set all the views of the api
module to a XML content-type, and to deactivate the decorator. This is done by adding a view.yml
file in the askeet/apps/frontend/modules/api/config/
directory:
all: has_layout: off http_metas: content-type: text/xml
note
The reason why the index
action returns a forward('api', 'error')
instead of a sfView::ERROR
in case of error is because all of the actions of the api
module use the same view. Imagine that both our index
action and another one, for instance popular
, end up with sfView::ERROR
: we would have to serve two identical error views (indexError.php
and popularError.php
) with the same content. The choice of a forward()
limits the repetition of code. However, it forces the execution of another action.
API response
Building an XML response is exactly like building an XHTML page. So none of the following should surprise you now that you have 16 days of symfony behind you.
api/index
action
public function executeQuestion() { $user = $this->authenticateUser(); if (!$user) { $this->error_code = 1; $this->error_message = 'login failed'; $this->forward('api', 'error'); } if (!$this->getRequestParameter('stripped_title')) { $this->error_code = 2; $this->error_message = 'The API returns answers to a specific question. Please provide a stripped_title parameter'; $this->forward('api', 'error'); } else { // get the question $question = QuestionPeer::getQuestionFromTitle($this->getRequestParameter('stripped_title')); if ($question->getUserId() != $user->getId()) { $this->error_code = 3; $this->error_message = 'You can only use the API for the questions you asked'; $this->forward('api', 'error'); } else { // get the answers $this->answers = $question->getAnswers(); $this->question = $question; } } }
questionSuccess.php
template
<?php echo '<?' ?>xml version="1.0" encoding="utf-8" ?> <rsp stat="ok" version="1.0"> <question href="<?php echo url_for('@question?stripped_title='.$question->getStrippedTitle(), true) ?>" time="<?php echo strftime('%Y-%m-%dT%H:%M:%SZ', $question->getCreatedAt('U')) ?>"> <title><?php echo $question->getTitle() ?></title> <tags> <?php foreach ($sf_user->getSubscriber()->getTagsFor($question) as $tag): ?> <tag><?php echo $tag ?></tag> <?php endforeach ?> </tags> <answers> <?php foreach ($answers as $answer): ?> <answer relevancy="<?php echo $answer->getRelevancyUpPercent() ?>" time="<?php echo strftime('%Y-%m-%dT%H:%M:%SZ', $answer->getCreatedAt('U')) ?>"><?php echo $answer->getBody() ?></answer> <?php endforeach ?> </answers> </question> </rsp>
Add a new routing rule for this API call:
api_question: url: /api/question/:stripped_title param: { module: api, action: question }
Test it
As the response of a REST API is simple XML, you can test it with a simple browser by requiring:
http://askeet/api/question/what-shall-i-do-tonight-with-my-girlfriend
Integrating an external API
Integrating an external API is not any harder than reading XML in PHP. As there is no immediate interest to integrate an existing external API in askeet, we will describe in a few words how to integrate the askeet API in a foreign website - whether built with symfony or not.
PHP5 comes bundled with SimpleXML, a very easy-to-use set of tools to interpret and loop through an XML document. With SimpleXML, element names are automatically mapped to properties on an object, and this happens recursively. Attributes are mapped to iterator accesses.
To reconstitute the list of answers to a question provided by the API into a simple page, all it takes is these few lines of PHP:
<?php $xml = simplexml_load_file(dirname(__FILE__).'/question.xml') ?> <h1><?php echo $xml->question->title ?></h1> <p>Published on <?php echo $xml->question['time'] ?></p> <h2>Tags</h2> <ul> <?php foreach ($xml->question->tags->tag as $tag): ?> <li><?php echo $tag ?></li> <?php endforeach ?> </ul> <h2>Answers to this question from askeet users</h2> <ul> <?php foreach ($xml->question->answers->answer as $answer): ?> <li> <?php echo $answer ?> <br /> Relevancy: <?php echo $answer['relevancy'] ?>% - Pulished on <?php echo $answer['time'] ?> </li> <?php endforeach ?> </ul>
Paypal donation
While we talk about external APIs, some of them are very simple to integrate and can bring a lot to your site. The Paypal donation API is a simple chunk of HTML code in which the email of the accountant must be included.
Wouldn't it be a good motivation for askeet users who generously answer questions to be able to receive a small donation from all the happy users who found their answer useful? The 'Donate' button could appear on the user profile page, and link to his/her Paypal donation page.
First, add a has_paypal
column to the User
table in the schema.xml
:
<column name="has_paypal" type="boolean" default="0" />
Rebuild the model, and add to the user/show
template the following code:
<?php if ($subscriber->getHasPaypal()): ?> <p>If you appreciated this user's contributions, you can grant him a small donation.</p> <form action="https://www.paypal.com/cgi-bin/webscr" method="post"> <input type="hidden" name="cmd" value="_xclick"> <input type="hidden" name="business" value="<?php echo $subscriber->getEmail() ?>"> <input type="hidden" name="item_name" value="askeet"> <input type="hidden" name="return" value="http://www.askeet.com"> <input type="hidden" name="no_shipping" value="1"> <input type="hidden" name="no_note" value="1"> <input type="hidden" name="tax" value="0"> <input type="hidden" name="bn" value="PP-DonationsBF"> <input type="image" src="http://images.paypal.com/legacy/images/x-click-but04.gif" border="0" name="submit" alt="Donate to this user"> </form> <?php endif ?>
Now a user must be given the opportunity to declare a Paypal account linked to his/her email address. It will be a good occasion to allow a user to modify his/her profile. If a logged user displays his/her own profile, an 'edit profile' must appear. It will link to a user/edit
action, used both to display the form and to handle the form submission. The 'edit profile' form will allow the modification of the password and the email address. The nickname, as it is used as a key, cannot be modified. Since you are familiar with symfony by now, the code will not be described here but included in the SVN repository.
See you Tomorrow
Developing a web service or integrating an external one should not give you any difficulty with symfony
Tomorrow will be the occasion to talk about filters, and to divide askeet.com in sub projects such as php.askeet.com and symfony.askeet.com with only a few lines of code. If you were not convinced about development speed and power with symfony, you may change your mind then.
As usual, today's code has been committed to the askeet SVN repository, under the /tags/release_day_17
tag. Questions ans suggestions about askeet and the advent calendar tutorials are welcome in the askeet forum. See you tomorrow!
This work is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.