Caution: You are browsing the legacy symfony 1.x part of this website.

17日目: API

1.0
Language

復習

昨日、askeetアプリケーションはオンラインに公開され、機能の調節と追加についてたくさんのフィードバックをすでに得ました。ユーザーの入力はWeb 2.0のアプリケーションのデザインの基礎で、アプリケーションのコンセプトが新しい場合でも、可能な限り、実験をしなければなりません。

しかし、21日に計画されていない機能を追加します。その前にaskeetを通して、Webアプリケーション開発の高度なテクニックをお見せします。今日学ぶのはHTTP認証を要求する外部APIのプログラミングです。

昨日、小さな変更をたくさん行ったので、askeetリポジトリからrelease_day_17とタグづけされたバージョンのaskeetを新しくダウンロードして今日のチュートリアルを始めることをお勧めします。

API

アプリケーションプログラミングインターフェイス(Application Programming Interface)、またはAPIはアプリケーション上の外部Webサイトを含められる特別なサービスのための開発者インターフェイスです。Google MapsまたはFlickrなどはAPIのおかげでインターネット上での多くのWebサイトを拡張するために使用されています。

askeetは例外を認めませんが、サービスの人気を高めるために、他のサイトで利用可能であるべきだということを信じます。11日目に開発されたRSSフィードはその要求への最初のアプローチでしたが、私たちはもっとよくすることができます。

askeetはユーザーによる質問への回答のAPIを提供する予定です。このAPIへのアクセスはHTTP認証を通したaskeetの登録ユーザーに限定されます。選択されたAPIのレスポンスフォーマットはRepresentational State TransferもしくはRESTで、WebにおいてメインAPIのたいていの出力と類似するレスポンスはシンプルなXMLブロックです:

<?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>

frontendアプリケーションの新しいモジュールにAPIを実装します。まずモジュールのスケルトンを生成するためにコマンドラインを使います:

$ symfony init-module frontend api

HTTP認証

APIの使用をaskeetの登録ユーザーに制限することを選びました。そのためには、HTTP認証プロセス、HTTPプロトコルの組み込みの認証メカニズムを使用します。以前見たWeb認証とは違います。なぜなら、Webページさえ必要ないからです。すべてのやりとりはHTTPヘッダーに取って代わられます。

6日目においてカスタムバリデータを含む認証メソッドを必要としています。そこで最初にリファクタリングしてUserPeerモデルクラスのログインコードを再配置します:

public static function getAuthenticatedUser($login, $password)
{
  $c = new Criteria();
  $c->add(UserPeer::NICKNAME, $login);
  $user = UserPeer::doSelectOne($c);
 
  // ニックネームが存在するか?
  if ($user)
  {
    // パスワードはOKか?
    if (sha1($user->getSalt().$password) == $user->getSha1Password())
    {
      return $user;
    }
  }
 
  return null;
}

新しいクラスメソッドであるUserPeer::getAutenticatedUser()myLoginValidator.class.php(お任せします)と新しいapi/indexWebサービスで使用されます:

<?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');
    }
    // 何かを行います
  }
 
  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()
  {
  }
}
 
?>

最初に、APIモジュール(preExecute()メソッド)のアクションを実行する前に、Webデバッグツールバーをオフにします。XMLであるこのアクションのビュー、ツールバーコードの挿入は有効ではないレスポンスを発生させます。

indexアクションが行う最初のことはログインとパスワードが提供されているかチェックし、askeetのアカウントと一致するかチェックします。一致しない場合、authenticateUser()メソッドは '401 'のHTTPヘッダーレスポンスを設定します。ユーザーのブラウザでポップアップするHTTP認証ウィンドウを呼び出します; ユーザーはログインとパスワードのリクエストを再投稿しなければなりません。

// 認証無しで、最初のAPIへのリクエスト
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
...  

// APIは内容を伴わない401ヘッダーを返す
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

// ログインボックスがユーザーのウィンドウ上に表示される
// ユーザーがログイン名/パスワードを入力すると、新しいGETがサーバーに送られる
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=

Autorization属性は再送信されるHTTPリクエストに追加されます。これはbase64でエンコードされた 'login:password'の文字列を含みます。それがauthenticateUser()メソッドで$_SERVER['PHP_AUTH_USER']$_SERVER['PHP_AUTH_PW']が探すものです。

note

base64は入力の暗号化バージョンを出力しません。base64でエンコードされた文字列をデコードするのはとても簡単で、パスワードを平文にします。たとえば ZmFicG90OnN5bWZvbnk=をデコードすると fabpot:symfonyです。インターネット上での平文でのパスワードの転送(Webフォームに入力されたときなど)は傍受されることを考慮しなければなりません。この理由からHTTP認証は重要ではない内容とサービスに限定しなければなりません。APIの呼び出しと同様に、追加の保護機能はHTTPSプロトコルを要求することによって実現されます。

ログインとパスワードが提供され、ユーザーのデータベースに存在するのであれば、indexアクションが実行されます。さもなければ、errorアクション(空)に転送し、errorSuccess.phpテンプレートを表示します:

<?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>

もちろん、apiモジュールのすべてのビューのContent-TypeをXMLに設定し、デコレータの動作を停止させることが必要です。askeet/apps/frontend/modules/api/config/ディレクトリのview.ymlによって行うことができます:

all:
  has_layout: off

  http_metas:
    content-type: text/xml

note

indexアクションがエラーの場合、sfView::ERRORの代わりにforward('api', 'error')を返すのはすべてのapiモジュールのアクションが同じビューを使用しているからです。indexアクションの他のもの、たとえばpopularsfView::ERRORで終わるのを想像してください。同じ内容で2つの同一のエラービュー(indexError.phppopularError.php)を提供しなければなりません。forward()を選べばコードの反復が抑制され、他のアクションの実行が強制されます。

APIレスポンス

XMLレスポンスの構築はXHTMLページの構築にそっくりです。16日目のチュートリアルを体験した後では次のコードに驚くことはないでしょう。

api/indexアクション

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
  {
    // 質問を取得する
    $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
    {
      // 回答を取得する
      $this->answers  = $question->getAnswers();
      $this->question = $question;
    }
  }
}

questionSuccess.phpテンプレート

<?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>

このAPI呼び出しのために新しいルーティングルールを追加します:

api_question:
  url:   /api/question/:stripped_title
  param: { module: api, action: question }

テストする

REST APIのレスポンスはシンプルなXMLなので、ブラウザによって簡単にテストできます:

http://askeet/api/question/what-shall-i-do-tonight-with-my-girlfriend

外部APIを統合する

PHPにおいて外部APIの統合はXMLの読み込みよりも難しいことではありません。askeetにおいて存在する外部APIを統合してもすぐに利益は得られません。symfonyで構築されているかに関わらずaskeet APIを外部のWebサイトに統合する方法について少し説明します。

PHP 5にはSimpleXMLがバンドルされています。SimpleXMLはXMLドキュメントを読み込みループするツールのセットで、とても簡単で使いやすいです。SimpleXMLによって要素名は自動的にオブジェクトのプロパティに割り当てられ、これは再帰的に行われます。属性はイテレータのアクセスに割り当てされます。

APIによって質問への回答リストをシンプルなページに再構築するために必要なのは、少量の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の寄付

外部のAPIを説明した上で、それらのいくつかを統合するのはとてもシンプルで、あなたのサイトに多くのものがもたらされます。Paypalの寄付APIは口座のEメールが必ず含まれなければならないシンプルなHTMLコードの塊です。

回答が役に立ったと判断した幸せなすべてのユーザーから回答ユーザーが少額の寄付を受け取りできるのはaskeetユーザーにとってよいモチベーションではないでしょうか? 'Donate'ボタンはユーザーのプロファイルページに表示され、ユーザーのPaypal donationページにリンクします。

最初に、has_paypalカラムをschema.xmlUserテーブルに追加します:

<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 ?>

現在、ユーザーはEメールアドレスにリンクするPaypalアカウントを宣言する機会が与えられなければなりません。ユーザーが自分のプロファイルを修正できるようにするよい機会です。ログインユーザーが自分自身のページを表示した場合、 'edit profile 'が表示されなければなりません。フォームの表示と投稿を処理するために使用されるuser/editアクションをリンクします。 'edit profile 'フォームはパスワードとEメールアドレスの修正を可能にします。キーとして使用されるニックネームは修正されることはありません。いまやあなたはsymfonyに慣れているので、コードはここでは表示しませんが、SVNリポジトリに含まれます。

それではまた明日

Webサービスを開発すること、外部のサービスを統合することをsymfonyで実現するのはそれほど難しくないでしょう。

明日は、フィルタについてお話をする機会があります。またコードを少し追加するだけでaskeet.comをたとえばphp.askeet.comtとsymfony.askeet.comとサブプロジェクトに分割します。まだsymfonyで開発するスピードとパワーに納得していなかったら、考えが変わるかもしれません。

いつもの通り、今日のコードを/tags/release_day_17とタグづけされたaskeet SVNリポジトリにコミットしておきました。アドベントカレンダーチュートリアルに関する質問と提案はaskeetフォーラムで歓迎します。それではまた明日!