目次


Agavi による MVC プログラミング入門

第 3 回 Agavi を使って、認証機能と管理機能を追加する

Agavi フレームワークを使用してスケーラブルな Web アプリケーションを作成する方法を学ぶ

Comments

コンテンツシリーズ

このコンテンツは全#シリーズのパート#です: Agavi による MVC プログラミング入門

このシリーズの続きに乞うご期待。

このコンテンツはシリーズの一部分です:Agavi による MVC プログラミング入門

このシリーズの続きに乞うご期待。

はじめに

この連載の第 2 回ではAgavi 世界の奥深くへと案内し、Web フォームから送信されたユーザー入力を処理する方法、MySQL と Doctrine の力を少し借りて、アプリケーションでデータベースにアクセスできるようにする方法を説明しました。また、アプリケーションの構成にモデルを追加し、これらのモデルを使ってアプリケーション・データベースから自動車のリストを読み込むことによって、皆さんの Agavi MVC 実装についての知識をさらに広げました。

しかし、データベースからレコードを読み取る方法を知っていても、それは話の半分でしかありません。残りの半分には、新規レコードの作成や、既存のレコードの変更などが関係してきます。そこで役に立つのが今回の記事です。以降のセクションでは WASP (Web Automobile Sales Platform) サンプル・アプリケーションをさらに賢いアプリケーションにするために、ユーザーが Web インターフェースを使ってレコードを作成、編集、削除できるようにする機能を追加します。また、Agavi のセキュリティー・フレームワークの基本を説明するとともに、特定の機能を認証済みユーザーだけが使えるようにする方法を説明します。それでは早速、本題に入りましょう。

データベース・レコードの追加

まずは図 1 を見て、WASP データベースの概要を思い出してください。

図 1. WASP データベース
WASP データベース
WASP データベース

前回の記事 (連載第2 回) は、データベースから個々の自動車のリストを読み取って表示する DisplayAction を作成したところで終わりました。リスト自体は、MySQL コマンド・プロンプトで SQL をそのまま使うという方法で手動により作成しました。けれども、WASP アプリケーションのビジネス目標の 1 つとなっているのは、販売店自体がデータベースにリストを追加し、サイトの管理人がそのリストについて検討して、承認するか削除するかの決定を下せるようにすることです。このビジネス目標には当然、次の機能が必要となってきます。

  • 販売店が自動車のリストをアップロードするためのインターフェース
  • WASP 管理者がアップロードされたリストを検討し、承認または削除するためのインターフェース
  • 2 種類のユーザーを区別するセキュリティー・モデルおよびアクセス制御モデル

上記の要件を実装するため、まず、販売店が Web フォームを使って新しいリストをデータベースに追加できるようにする CreateAction を作成します。それには以下のようにして、Agavi ビルド・スクリプトを起動し、Listing モジュールに含まれるアクションと 3 つのビューを初期化します。

shell> agavi action-wizard
Module name: Listing
Action name: Create
Space-separated list of views to create for Create [Success]: Input Error Success

この CreateAction の新規ルートを、アプリケーションのルーティング・テーブルに追加する必要もあります (リスト 1)。

リスト 1. Listing/CreateAction ルートの定義
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
  <ae:configuration>
    <routes>
      ...
            
      <!-- action for listing pages "/listing" -->
      <route name="listing" pattern="^/listing" module="Listing">
        <route name=".create" pattern="^/create$" action="Create" />
        <route name=".display" pattern="^/display/(id:\d+)$" action="Display" />
      </route>
            
    </routes>
  </ae:configuration>
</ae:configurations>

CreateAction のデフォルトの振る舞いは、図 1 に示したデータベース・フィールドに対応するフィールドを持つ Web フォームを表示することです。それには CreateAction に getDefaultViewName() メソッドを定義して、すべての GET リクエストで CreateInputView がデフォルト表示されるように指定します (リスト 2)。

リスト 2. Listing/CreateAction の定義
<?php
class Listing_CreateAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Input';
  }
}
?>

対応する CreateInput テンプレート・ファイルを、必要な Web フォームで更新します (リスト 3)。このフォームの CSS ルールと JavaScript コードは、記事に付属のコード・アーカイブ (「ダウンロード」を参照) に含まれています。

リスト 3. Listing/CreateInput テンプレート
<script src="/js/form.js"></script>
<h3>Add Listing</h3>
<form action="<?php echo $ro->gen(null); ?>" method="post">
  <fieldset>
    <legend>Owner Information</legend>
  	<label for="OwnerName" class="required">Name:</label>
  	<input id="OwnerName" type="text" name="OwnerName" />
  	<p/>
  	<label for="OwnerTel">Telephone number:</label>
  	<input id="OwnerTel" type="text" name="OwnerTel" />
  	<p/>
  	<label for="OwnerEmail" class="required">Email address:</label>
  	<input id="OwnerEmail" type="text" name="OwnerEmail" />
  	<p/>
  	<label for="OwnerCity" class="required">City:</label>
  	<input id="OwnerCity" type="text" name="OwnerCity" />
  	<p/>
  	<label for="OwnerCountryID" class="required">Country:</label>
  	<select id="OwnerCountryID" name="OwnerCountryID">
  	<?php foreach ($t['countries'] as $c): ?>
  	<?php echo "<option value=\"$c[CountryID]\">" . $c['CountryName'] . 
  	 "</option>"; ?>
  	<?php endforeach; ?>
  	</select>
  	<p/>
	</fieldset>
	
  <fieldset>	
    <legend>Vehicle Information</legend>
  	<label for="VehicleManufacturerID" class="required">Manufacturer:</label>
  	<select id="VehicleManufacturerID" name="VehicleManufacturerID">
  	<?php foreach ($t['manufacturers'] as $m): ?>
  	<?php echo "<option value=\"$m[ManufacturerID]\">" . $m['ManufacturerName'] . 
  	 "</option>"; ?>
  	<?php endforeach; ?>
  	</select>
  	<p/>
  	
	  <label for="VehicleModel" class="required">Model:</label>
	  <input id="VehicleModel" type="text" name="VehicleModel" />
  	<p/>
  	
  	<label for="VehicleYear" class="required">Year of manufacture:</label>
  	<input id="VehicleYear" type="text" name="VehicleYear" size="4" 
  	 style="width:80px" />
  	<p/>
  	
  	<label for="VehicleColor" class="required">Color:</label>
  	<input id="VehicleColor" type="text" name="VehicleColor" />
  	<p/>
  	
  	<label for="VehicleMileage" class="required">Mileage:</label>
  	<input id="VehicleMileage" type="text" name="VehicleMileage" size="6" 
  	 style="width:100px" />
  	<p/>
  	
  	<label for="VehicleAccessoryBit" style="height:130px">Accessories:</label>
  	<input id="VehicleAccessoryBit_1" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="1" style="width:2px" />Power steering
  	<br/>
  	<input id="VehicleAccessoryBit_2" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="2" style="width:2px" />Power windows
  	<br/>
  	<input id="VehicleAccessoryBit_4" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="4" style="width:2px" />Audio system
  	<br/>
  	<input id="VehicleAccessoryBit_8" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="8" style="width:2px" />Video system
  	<br/>
  	<input id="VehicleAccessoryBit_16" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="16" style="width:2px" />Keyless entry system
  	<br/>
  	<input id="VehicleAccessoryBit_32" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="32" style="width:2px" />GPS
  	<br/>
  	<input id="VehicleAccessoryBit_64" type="checkbox" name="VehicleAccessoryBit[]" 
  	 value="64" style="width:2px" />Alloy wheels
  	<p/>
  	
  	<label for="data[VehicleIsFirstOwned]">Ownership:</label>
  	<input id="data[VehicleIsFirstOwned]" type="checkbox" 
  	 name="data[VehicleIsFirstOwned]" value="1" style="width:2px" />First owner
  	<p/>
  	
  	<label for="VehicleIsCertified">Certification:</label>
  	<input id="VehicleIsCertified" type="checkbox" name="VehicleIsCertified" 
  	 value="1" style="width:2px" 
  	  onClick="javascript:handleInputDisplayOnCheck('VehicleIsCertified', 
  	  'divVehicleCertificationDate')"/>Fully certified
  	<p/>
  	
  	<div id="divVehicleCertificationDate" style="display:none">
    	<label for="VehicleCertificationDate" class="required">
    	 Certificate issued in:</label>
    	<select id="VehicleCertificationDate_mm" name="VehicleCertificationDate_mm">
    	<?php for ($x=1; $x<=12; $x++): ?>
    	<?php echo "<option value=\"$x\">" . 
    	 date("F", mktime(null, null, null, $x, 1)) . "</option>"; ?>
    	<?php endfor; ?>
    	</select>
    	<select id="VehicleCertificationDate_yyyy" 
    	 name="VehicleCertificationDate_yyyy">
    	<?php for ($x=1990; $x<=date('Y'); $x++): ?>
    	<?php echo "<option value=\"$x\">$x</option>"; ?>
    	<?php endfor; ?>
    	</select>
    	<p/>
  	</div>
  	
  	<label for="VehicleSalePriceMin" class="required">Sale price (min):
  	 </label>
  	<input id="VehicleSalePriceMin" type="text" name="VehicleSalePriceMin" 
  	 size="6" style="width:100px" />
  	<p/>

  	<label for="VehicleSalePriceMax" class="required">Sale price (max):
  	 </label>
  	<input id="VehicleSalePriceMax" type="text" name="VehicleSalePriceMax" 
  	 size="6" style="width:100px" />
  	<p/>
  	
  	<label for="VehicleSalePriceIsNegotiable"> </label>
  	<input id="VehicleSalePriceIsNegotiable" type="checkbox" 
  	 name="VehicleSalePriceIsNegotiable" value="1" style="width:2px" />Negotiable
  	<p/>
  	
  	<label for="Note">Description:</label>
  	<textarea id="Note" name="Note" style="width:300px; height:200px"
  	 ></textarea>
  	<p/>
	</fieldset>
	
	<input type="submit" name="submit" class="submit" value="Submit Listing" />
</form>

<script>
handleInputDisplayOnCheck('VehicleIsCertified', 'divVehicleCertificationDate');
</script>

このフォームのフィールドのうち、CountryManufacturer の 2 つは選択リストであり、ここに含まれる値はそれぞれ country テーブルと manufacturer テーブルから提供されることに注意してください。これらの値を取得してテンプレート変数として使用可能にするには、$WASP_ROOT/app/modules/Listing/lib/view/WASPListingBaseView.class.php にある Listing モジュールの基本ビューを編集し、以下のように setInputViewAttributes() メソッドを追加します (リスト 4)。

リスト 4. Listing/WASPListingBaseView の定義
<?php
/**
 * The base view from which all Listing module views inherit.
 */
class WASPListingBaseView extends WASPBaseView
{
  // set values from selection lists
  function setInputViewAttributes() {
    $q = Doctrine_Query::create()
          ->from('Country c');    
    $this->setAttribute('countries', $q->fetchArray());
    
    $q = Doctrine_Query::create()
          ->from('Manufacturer m');
    $this->setAttribute('manufacturers', $q->fetchArray());
  }  
}
?>

setInputViewAttributes() メソッドを WASPListingBaseView に追加すると、このビューを継承するすべてのビューでこのメソッドが使用可能になるため、このコードを個々のビューごとにコピーする必要がなくなります。

続いて CreateInputView を編集し、このメソッドを executeHtml() メソッドのなかで呼び出します (リスト 5)。

リスト 5. Listing/CreateInputView の定義
<?php
class Listing_CreateInputView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);
    $this->setInputViewAttributes(); 
  }
}
?>

Web ブラウザーで URL http://wasp.localhost/listing/create にアクセスすると、図 2 のようなフォームが表示されます。

図 2. 新規リストを追加するための Web フォーム
新規リストを追加するための Web フォーム
新規リストを追加するための Web フォーム

ユーザーがフォームを送信すると、クライアント・ブラウザーは入力されたデータを POST トランザクションでサーバーに送信します。Agavi は、入力データの検証にパスした場合、CreateAction の executeWrite() メソッドを呼び出します。このメソッドは、入力を処理し、それを新規レコードとしてデータベースに保存しなければなりません。リスト 6 に、このタスクを実行するコードを記載します。

リスト 6. Listing/CreateAction の定義
<?php
class Listing_CreateAction extends WASPListingBaseAction
{
  ...
  public function executeWrite(AgaviRequestDataHolder $rd)
  {    
    try {
      // initialize object
      $listing = new Listing();
      
      // populate with validated input
      $listing->fromArray($rd->getParameters());      
      $listing->VehicleAccessoryBit = 
       array_sum($rd->getParameter('VehicleAccessoryBit'));

      // set some values manually
      $listing->RecordDate = date('Y-m-d', mktime());
      $listing->DisplayStatus = 0;
      
      // save record and get record ID
      $listing->save();
      $id = $listing->RecordID;      
      return 'Success';
    } catch (Exception $e) {    
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  }
}
?>

リスト 6 のメソッドは、Listing モデルの新規インスタンスを初期化し、初期化したインスタンスに Web フォームから送信された入力データを取り込みます。入力データに必要なあらゆるカスタム調整 (例えば、listing.VehicleAccessoryBit ビットマスクを構成するビットを追加したり、リストの表示ステータスを非表示に設定したりするなど) もこの時点で行われます。その上で、モデルの save() メソッドを呼び出すことによって必要な INSERT クエリーを作成して実行することにより、レコードはデータベースに保存されます。

レコードが正常に保存されると、CreateAction が CreateSuccessView. をレンダリングします。リスト 7 に、CreateSuccess テンプレートのコードを記載します。

リスト 7. Listing/CreateSuccess テンプレート
<h3>Add Listing</h3>
Your submission has been accepted.
<p/>
A moderator will review it shortly and, if approved, it will be added 
to the public database within the next 48 hours.

検証エラーが発生した場合、または何らかの理由でレコードをデータベースに保存できない場合には、CreateAction が CreateErrorView をレンダリングします。この CreateErrorView はエラーの原因に応じて、エラーのあるフィールドを強調表示して CreateInput テンプレートを再レンダリングするか、あるいはテンプレート変数にエラー・メッセージを組み込んで CreateError テンプレートをレンダリングします。

リスト 8 に、CreateErrorView のコードを記載します。

リスト 8. Listing/CreateErrorView の定義
<?php
class Listing_CreateErrorView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);

    // if validation errors, render input template
    if ($this->container->getValidationManager()->getReport()->count()) {
      $this->setInputViewAttributes(); 
      $this->getLayer('content')->setTemplate('CreateInput');   
    }   
  }
}
?>

CreateError テンプレートのコードは、リスト 9 のとおりです。

リスト 9. Listing/CreateError テンプレート
<h3>Add Listing</h3>
An error occurred while processing your submission. Please try again later.
<br/><br/>
<code style="color: red">
<?php echo $t['error']; ?>
</code>

これまでの説明を読んで、上記のように入力検証エラーを処理する手法は、前に説明した手法とは異なっていることにお気付きでしょう (連載第 2 回の「フォーム入力フィルターの使用」に記載した ContactAction の定義を参照してください)。前回の手法では、アクションの handleError() メソッドをオーバーライドして、該当する InputView を直接レンダリングしました。しかし今回は、アクションの handleError() メソッドには手を付けていません。入力テンプレートとエラー・テンプレートのどちらをレンダリングするかは、ErrorView が決定します。

些細な違いのように思えるかもしれませんが、実際には極めて重要な違いです。最初の手法は簡単とは言え、さまざまな出力タイプを扱うとなると、うまくいきません。例えば、HTML 出力を処理するときには入力フォームを再表示する一方、SOAP 出力を処理するときには入力フォームではなく、エラー・メッセージを表示したいという場合があります。2 番目の手法 (Agavi の中心的開発者たちが推奨する手法) であれば、このような場合に対処することができます。エラーの処理方法を決定するのはアクションではなく、ErrorView だからです。そして ErrorView は複数の出力タイプを処理できる一方、アクションでは複数の出力タイプは処理できません。

話のついでとして言っておくと、この連載のサンプルでは try-catch() ブロックを使用して例外を捕捉し、処理していますが、必ずしもこうしなければならないというわけではありません。Agavi は例外を捕捉すると、汎用の「500 Internal Server Error」ページをクライアントに返す一方で、捕捉されていない例外を自動的に処理します。それぞれのアクションごとに例外を捕捉して処理するとなると作業が遙かに増えますが、その一方、クライアントが返すエラー・ページをより柔軟に制御できることになります。どちらの手法を使うべきかは、個々のアプリケーションの要件次第です。しかし、使用する手法をいったん決めたら、それを貫いてください。途中で手法を変えたりすると、矛盾を生じさせることになります。

これもお気付きかと思いますが、上記の説明では 1 つの重要なコンポーネント、入力バリデーターについては触れていません。その理由は、リスト 3 の Web フォームは、これまで見てきたコードよりも非常に長く、しかもかなり複雑なので、このフォームに伴う入力バリデーターについては詳細に説明するだけの価値があるためです。それが、次のセクションの話題です。

複雑な入力バリデーターの使用

リスト 3 の Web フォームを見ると、このフォームには入力に多種多様なバリデーターが必要であることが一目でわかるはずです。ModelDescription などのストリング・フィールドを検証するには、リスト 10 に記載する AgaviStringValidator を使用します。

リスト 10. ストリングの検証例
<validator class="string">
  <arguments>
    <argument>VehicleModel</argument>
  </arguments>
  <errors>
    <error>ERROR: Vehicle model is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="required">true</ae:parameter>
  </ae:parameters>
</validator>

<validator class="string">
  <arguments>
    <argument>Note</argument>
  </arguments>
  <ae:parameters>
    <ae:parameter name="required">false</ae:parameter>
  </ae:parameters>
</validator>

数値フィールドは、リスト 11 の AgaviNumberValidator を使って検証することができます。

リスト 11. 数値の検証例
<validator class="number">
  <arguments>
    <argument>VehicleMileage</argument>
  </arguments>
  <errors>
    <error>ERROR: Vehicle mileage is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="type">int</ae:parameter>
    <ae:parameter name="min">1</ae:parameter>
    <ae:parameter name="max">99999</ae:parameter>
    <ae:parameter name="required">true</ae:parameter>
  </ae:parameters>        
</validator>      

<validator class="number">
  <arguments>
    <argument>VehicleSalePriceMin</argument>
  </arguments>
  <errors>
    <error>ERROR: Vehicle sale price (min) is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="type">int</ae:parameter>
    <ae:parameter name="min">0</ae:parameter>
    <ae:parameter name="required">true</ae:parameter>
  </ae:parameters>
</validator>

AgaviNumberValidator は、特定の範囲に制限しなければならない入力にも役立ちます。例えば、MySQL の YEAR データ型が現在許容する値の範囲は 1901 から 2155 です。そのため、このフィールドを対象とする入力は 1901 から 2155 の値でなければなりません (リスト 12)。

リスト 12. 年の検証例
<validator class="number">
  <arguments>
    <argument>VehicleYear</argument>
  </arguments>
  <errors>
    <error for="required">ERROR: Vehicle year of manufacture is missing 
    </error>
    <error for="type">ERROR: Vehicle year of manufacture is invalid</error>
    <error for="min">ERROR: Vehicle year of manufacture is before 1900
    </error>
    <error for="max">ERROR: Vehicle year of manufacture is after 2020
    </error>
  </errors>
  <ae:parameters>
    <ae:parameter name="type">int</ae:parameter>
    <ae:parameter name="min">1901</ae:parameter>
    <ae:parameter name="max">2020</ae:parameter>
    <ae:parameter name="required">true</ae:parameter>
  </ae:parameters>
</validator>

E メール・アドレスを検証するには、リスト 13 の AgaviEmailValidator を使用します。

リスト 13. E メール・アドレスの検証例
<validator class="email">
  <arguments>
    <argument>OwnerEmail</argument>
  </arguments>
  <errors>
    <error>ERROR: Email address is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="required">true</ae:parameter>
  </ae:parameters>
</validator>

電話番号などといった、さらに複雑なストリングの検証には AgaviRegexValidator を使用します (リスト 14)。

リスト 14. 正規表現の検証例
<validator class="regex">
  <arguments>
    <argument>OwnerTel</argument>
  </arguments>
  <errors>
    <error>ERROR: Number is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="required">false</ae:parameter>
    <ae:parameter name="pattern">#^[0-9]{6,25}$#</ae:parameter>
    <ae:parameter name="match">true</ae:parameter>
  </ae:parameters>
</validator>

Agavi では、AND または OR 条件を使用してバリデーターをネストすることも可能です。この機能の有効な適用方法の 1 つは、POST 送信の中に特定のフィールドが含まれていない場合、バリデーターを使って自動的にデフォルト値を設定したそのフィールドを追加することです。チェック・ボックス・フィールドはその好例で、通常、これらのフィールドはユーザーが明示的にチェック・マークを付けない限り、POST 送信には組み込まれません。その場合、AgaviOrValidator を AgaviSetValidator と組み合わせて使用することで、チェック・ボックス・フィールドにデフォルト値を割り当ててから、アクションにエクスポートすることができます。

例えばリスト 15 では、$_POST['VehicleIsFirstOwned'] が POST 送信の中に含まれていない場合には、$_POST['VehicleIsFirstOwned'] を動的に作成し、その値を 0 に設定します。

リスト 15. デフォルト値の設定例
<validator class="or">
  <validator class="number">
    <arguments>
      <argument>VehicleIsFirstOwned</argument>
    </arguments>
    <errors>
      <error>ERROR: Vehicle ownership status is invalid</error>
    </errors>
    <ae:parameters>
      <ae:parameter name="required">false</ae:parameter>
      <ae:parameter name="min">0</ae:parameter>
      <ae:parameter name="max">1</ae:parameter>
    </ae:parameters>
  </validator>      

  <validator class="set">
    <ae:parameters>
      <ae:parameter name="export">VehicleIsFirstOwned</ae:parameter>
      <ae:parameter name="value">0</ae:parameter>
    </ae:parameters>
  </validator>      
</validator>

Agavi には、日付と時刻を対象とした極めて高度なバリデーターも用意されています。この AgaviDateTimeValidator は指定された日付が有効であるかどうかをチェックするだけでなく、入力値を異なる日付または時刻の形式に変換します。これは、データベースの DATETIME フィールドを処理する際には特に便利なバリデーターとなります。このフィールドは、特定の形式での日付と時刻を要求するからです。

リスト 16 の AgaviDateTimeValidator は、車両証明書 (Vehicle Certification) の月と年をそれぞれ異なるフォーム・フィールドから読み取り、その月と年が有効な日付を構成することを検証した上で、MySQL DATE フィールドに挿入できるように YYYY-MM-DD 形式に変換します。

リスト 16. 日付/時刻の検証例
<validator class="datetime">
  <arguments>
    <argument name="AgaviDateDefinitions::MONTH">VehicleCertificationDate_mm
    </argument>
    <argument name="AgaviDateDefinitions::YEAR">VehicleCertificationDate_yyyy
    </argument>
  </arguments>
  <errors>
    <error>ERROR: Vehicle certification date is missing or invalid</error>
  </errors>
  <ae:parameters>
    <ae:parameter name="required">true</ae:parameter>
    <ae:parameter name="check">true</ae:parameter>
    <ae:parameter name="export">VehicleCertificationDate</ae:parameter>
    <ae:parameter name="cast_to">
      <ae:parameters>
          <ae:parameter name="type">date</ae:parameter>
          <ae:parameter name="format">yyyy-MM-dd</ae:parameter>
      </ae:parameters>
    </ae:parameter>
  </ae:parameters>
</validator>

注意する点として、AgaviDateTime バリデーターには、Agavi 構成変数 use_translation を true に設定する必要があります。この変数は、$WASP_ROOT/app/config/settings.xml ファイルで設定することができます。

CreateAction バリデーターの一式については、記事に付属のコード・アーカイブ (「ダウンロード」を参照) を参照してください。

データベース・レコードのリスト

ここからは再び WASP 管理インターフェースの作成を進めます。説明が複雑にならないように、管理者に必要な機能は、リストの表示、編集、削除のみであるという前提にします。これらの機能に対応する AdminIndexAction、AdminEditAction、AdminDeleteAction のすべてを、以降のセクションで作成していきます。まずは Agavi ビルド・スクリプトを起動して、以下の内容を入力してください。

shell> agavi action-wizard
Module name: Listing
Action name: AdminIndex
Space-separated list of views to create for AdminIndex [Success]: Error Success
...
Module name: Listing
Action name: AdminDelete
Space-separated list of views to create for AdminDelete [Success]: Error Success
...
Module name: Listing
Action name: AdminEdit
Space-separated list of views to create for AdminEdit [Success]: Error Success Input 
Redirect404

ここで少し補足しますが、上記を見ると明らかなように、私が AdminIndexAction を配置しているのは Listing モジュールです。上で挙げた他の 2 つの管理アクションについても、同じようにこのモジュールに配置しています。なぜここに配置するかと言うと、これらのアクションはいずれもリストの管理に関連しているため、アクションの拠点としては Listing モジュールがふさわしいと思うからです。この観点からすると、ファイル・システムのレイアウトは以下のようになります。

app/modules/Listing/actions/DisplayAction.class.php
app/modules/Listing/actions/CreateAction.class.php
app/modules/Listing/actions/AdminIndexAction.class.php
app/modules/Listing/actions/AdminEditAction.class.php
app/modules/Listing/actions/AdminDeleteAction.class.php

一方、別の観点から見ると、この 3 つのアクションは管理者専用のものなので、別個の例えば Admin という名前のモジュールに分類しなければなりません。この観点でファイル・システムをレイアウトすると、以下のようになります。

app/modules/Listing/actions/DisplayAction.class.php
app/modules/Listing/actions/CreateAction.class.php
...
app/modules/Admin/actions/ListingIndexAction.class.php
app/modules/Admin/actions/ListingEditAction.class.php
app/modules/Admin/actions/ListingDeleteAction.class.php

他のフレームワークから Agavi に移行したユーザーの間で混乱しがちな主な点は、上記の 2 つの方法のうち、どちらが正しいか、あるいはどちらが最善であるかを見分けるところにあります。しかし、ここで知っておかなければならない重要なことは、上記の分類は両方とも (そして、ここでは取り上げていませんが、他に考えられる分類も) 有効だという点です。Agavi はアクションをモジュールに編成する際に適用する基準について、特別なルールを設けていません。モジュールは、関連するアクションをグループ化するのに便利な方法にすぎず、アクションがどのように呼び出され、使用されるかについては何の関係も持たないからです。したがって、自分と自分のアプリケーションにとって役に立つ分類を自由に選択してください。あるいは、整理整頓にそれほどこだわらないのであれば、すべてのアクションを Default モジュールに放り込んで、分類については一切考えないとう選択肢もあります。

作業に戻って、今度は 3 つの新規アクションのルートを Agavi のルーティング・テーブルに追加します (リスト 17)。

リスト 17. Listing/Admin* ルートの定義
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
  <ae:configuration>
    <routes>
       ...

      <!-- action for admin listing pages "/admin/listing/*" -->
      <route name="admin.listing" pattern="^/admin/listing" module="Listing">
        <route name=".index" pattern="^/index$" action="AdminIndex" />
        <route name=".edit" pattern="^/edit/(id:\d+)$" action="AdminEdit" />
        <route name=".delete" pattern="^/delete$" action="AdminDelete" />
      </route>
                        
    </routes>
  </ae:configuration>
</ae:configurations>

この分岐のなかで最も単純なのは AdminIndexAction です。このアクションは、listing テーブル内の全レコードのリストを表示し、それぞれのレコードを選んで編集および削除できるようにするにすぎません。そこで、まずはこのアクションから取り掛かります。アクションの executeRead() メソッドを編集し、データベース内のすべてのリストを取得するための Doctrine クエリーを追加してください (リスト 18)。

リスト 18. Listing/AdminIndexAction の定義
<?php
class Listing_AdminIndexAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
  
  public function executeRead(AgaviRequestDataHolder $rd) 
  { 
    try {
      // get input variables
      $id = $rd->getParameter('id');

      // create query
      $q = Doctrine_Query::create()
            ->from('Listing l')
            ->leftJoin('l.Manufacturer m')
            ->leftJoin('l.Country c');
      $result = $q->fetchArray();
      
      // set view variables
      $this->setAttribute('records', $result);
      return 'Success';
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  }
  
}
?>

例外が発生しなければ、レコードセットはテンプレート変数 $t['records'] によって AdminIndexSuccess テンプレートに変換されて、簡潔な HTML の表に表示されます (リスト 19)。

リスト 19. Listing/AdminIndexSuccess テンプレート
<h3>View All Listings</h3>
<?php if (count($t['records']) == 0): ?>
No records available
<?php else: ?>

<div id="list">
  <form action="<?php echo $ro->gen('admin.listing.delete'); ?>" method="post" >
  <table cellspacing="5">
    <tr>
      <td class="key"></td>
      <td class="key"></td>
      <td class="key">Submission Date</td>
      <td class="key">Manufacturer</td>
      <td class="key">Model</td>
      <td class="key">Year</td>
      <td class="key">Mileage</td>
      <td class="key">Color</td>
      <td class="key"></td>
    </tr>  
    <?php foreach ($t['records'] as $record): ?>
    <tr>
      <td><input type="checkbox" name="id[]" 
       value="<?php echo $record['RecordID']; ?>" style="width:2px" />
      </td>
      <td><?php echo $record['RecordID']; ?></td>
      <td><?php echo date('d M Y', strtotime($record['RecordDate'])); ?>
      </td>
      <td><?php echo $record['Manufacturer']['ManufacturerName']; ?>
      </td>
      <td><?php echo $record['VehicleModel']; ?></td>
      <td><?php echo $record['VehicleYear']; ?></td>
      <td><?php echo $record['VehicleMileage']; ?></td>
      <td><?php echo $record['VehicleColor']; ?></td>
      <td><a href="<?php echo $ro->gen('admin.listing.edit', 
       array('id' => $record['RecordID'])); ?>">Edit</a></td>
    </tr>  
    <?php endforeach; ?>
    <tr>
      <td colspan="9"><input type="submit" name="submit" style="width:150px" 
       value="Delete Selected" /></td>
    </tr>    
  </table>  
  </form>
</div>
<?php endif; ?>

$ro->gen() メソッドで、レコードを編集および削除するためのルートを、前に作成済みのルート名を使って動的に生成していることにも注目してください。

実際の動作を見るには、Web ブラウザーで http://wasp.localhost/admin/listing/index にアクセスし、現在データベースにある自動車のリストの要約を表示してください。図 3 に、このビューの表示例を示します。

図 3. データベース・レコードの要約ビュー
データベース・レコードの要約ビュー
データベース・レコードの要約ビュー

ページングやソートの機能はまだありませんが、これらの機能を追加するまでは、もう少し辛抱してください。今後の連載でまもなく取り上げます。

新しいマスター・テンプレートの使用

図 3 から、AdminIndexAction によって生成された要約ビューのレイアウトと外観は、WASP アプリケーションの他の公開ページと同じであることがわかります。これは当然のことで、すべてのビューは $WASP_ROOT/app/templates/Master.php に置かれた同じマスター・テンプレートを使用しているからです。しかし、美観のため、あるいはユーザーにアプリケーションの別のセクションに移動したことを視覚的に強調するために、アプリケーションの管理ビューが他のビューとは異なるルック・アンド・フィールになるよう、顧客が要望することは珍しくありません。

管理ビューを異なるルック・アンド・フィールにするのは、Agavi ではそれほど難しい話ではありません。別のマスター・テンプレートを作成して Agavi に登録し、そのテンプレートをビューの setupHtml() メソッドで参照すればよいだけのことです。

ステップ 1: マスター・テンプレートを新規に作成する

まず始めに、$WASP_ROOT/app/templates/AdminMaster.php に新規テンプレートを作成し、そのテンプレートにリスト 20 のコードを入力します。

リスト 20. AdminMaster テンプレート
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" 
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <base href="<?php echo $ro->getBaseHref(); ?>" />
    <link rel="stylesheet" type="text/css" href="/css/default.css" />
    <link rel="stylesheet" type="text/css" href="/css/admin.css" />
    <title><?php if(isset($t['_title'])) echo htmlspecialchars($t['_title']) . 
     ' - '; echo AgaviConfig::get('core.app_name'); ?></title>
  </head>
  <body>
    <!-- begin header -->
    <div id="header">
      <div id="logo">
        <img src="/images/logo-admin.jpg" />
      </div>
      <div id="menu">
      </div>
    </div>
    <!-- end header -->
    
    <!-- begin body -->
    <div id="body"> 
      <?php echo $inner; ?>
    </div>
    <!-- end body -->
    
    <!-- begin footer -->
    <div id="footer">
      <p>Powered by <a href="http://www.agavi.org/">Agavi</a>. 
      Licensed under <a href="http://www.creativecommons.org/">Creative Commons
      </a>.</p>
    </div>
    <!-- end footer -->
  </body>
</html>

ついでに、以下の追加ルールを設定した新しい CSS ファイル (リスト 21) を作成して $WASP_ROOT/pub/css/admin.css に保存します。

リスト 21. Listing/AdminMaster テンプレートのスタイルシート
#header {
  background: white;
  border-bottom: dashed 2px black;
}

#logo {
  padding-left: 10px;
}

#menu {
  background: white;
}

#menu a {
  color: black;
}

#footer {
  background: black;
}


#footer a {
  color: white;
}

#body form fieldset legend {
  color: black;
}

ステップ 2: 新規レイアウトを作成して登録する

次に、Agavi に新規テンプレートを認識させます。HTML の出力用にこの新規テンプレートを使用する新しいレイアウトを $WASP_ROOT/app/config/output_types.xml に登録します。追加しなければならない内容は、以下のとおりです。

<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/output_types/1.0">
  
  <ae:configuration>    
    <output_types default="html">      
      <output_type name="html">
        
        ...   
        <layouts default="standard">
          <!-- standard layout with a content and a decorator layer -->
          <layout name="standard">
            <!-- content layer without further params. 
            this means the standard template is used, i.e. the one with the 
            same name as the current view -->
            <layer name="content" />
            <!-- decorator layer with the HTML skeleton, navigation etc; 
            set to a specific template here -->
            <layer name="decorator">
              <ae:parameter name="directory">%core.template_dir%</ae:parameter>
              <ae:parameter name="template">Master</ae:parameter>
            </layer>
          </layout>
          
          <layout name="admin">
            <layer name="content" />
            <layer name="decorator">
              <ae:parameter name="directory">%core.template_dir%</ae:parameter>
              <ae:parameter name="template">AdminMaster</ae:parameter>
            </layer>
          </layout>         
          ...

        </layouts>
        
        <ae:parameter name="http_headers">
          <ae:parameter name="Content-Type">text/html; charset=UTF-8
          </ae:parameter>
        </ae:parameter>
        
      </output_type>      
    </output_types>    
  </ae:configuration>
  ...
 
</ae:configurations>

ステップ 3: 新規レイアウトを必要に応じて使用する

この新しいレイアウトをビューで使用するには、ビューの setupHtml() メソッドにレイアウト名を追加引数として渡します。AdminIndexSuccessView を、例えばリスト 22 のように更新します。

リスト 22. Listing/AdminIndexSuccessView の定義
<?php
class Listing_AdminIndexSuccessView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd, 'admin');
  }
}
?>

リストの要約ページにもう一度アクセスすると、新しいレイアウトが有効になっているはずです (図 4)。

図 4. レイアウトが新しくなった変更後の要約ビュー
レイアウトが新しくなった変更後の要約ビュー
レイアウトが新しくなった変更後の要約ビュー

データベース・レコードの削除

プレースホルダー・クラスとルートは作成済みなので、後は AdminDeleteAction を機能させればよいだけです。まず、AdminDeleteAction に渡すレコード ID の配列を対象としたバリデーターを追加します (リスト 23)。

リスト 23. Listing/AdminDeleteAction バリデーター
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Listing/config/validators.xml"
>
  <ae:configuration>
    
    <validators method="write">
      <validator class="number">
        <arguments base="id[]">
          <argument />
        </arguments>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
          <ae:parameter name="min">1</ae:parameter>
        </ae:parameters>
      </validator>    
    </validators>
    
  </ae:configuration>
</ae:configurations>

次に、これらのレコード ID を読み取って、対応するレコードをデータベースから削除するように AdminDeleteAction の executeWrite() メソッドを更新します (リスト 24)。

リスト 24. Listing/AdminDeleteAction の定義
<?php
class Listing_AdminDeleteAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
  
  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    try {
      // get record ids
      $ids = $rd->getParameter('id');
      
      foreach ($ids as $id) {
        // delete record from database
        $q = Doctrine_Query::create()
              ->delete('Listing')
              ->addWhere('RecordID = ?', $id);
        $result = $q->execute();        
      }
      
      return 'Success';     
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  } 
}
?>

AdminDeleteSuccess テンプレートと AdminDeleteError テンプレートをそれぞれリスト 25リスト 26 のようにセットアップする必要もあります。

リスト 25. Listing/AdminDeleteSuccess テンプレート
<h3>Delete Listing</h3>
The selected record(s) were successfully deleted!
リスト 26. Listing/AdminDeleteError テンプレート
<h3>Delete Listing</h3>
The selected record(s) could not be deleted. Please try again.
<br/><br/>
<code style="color: red">
<?php echo $t['error']; ?>
</code>

これで作業は完了です。http://wasp.localhost/admin/listing/index にアクセスして、要約ページがどのように機能するかを確かめてください。それには、いくつかのレコードを選択し、Delete Selected をクリックします。

データベース・レコードの編集

既存のレコードを編集する操作は、新規レコードの追加操作とは多少異なります。レコードの編集操作では、ユーザーに対し、既存のレコードの内容がフィールドに事前に入力された状態で入力フォームを表示しなければなりません。幸い、ここでも Agavi の FormPopulationFilter が救いの手を差し伸べて、このタスクをかなり楽にしてくれます。

例えば、AdminEditAction を開いて executeRead() メソッドを更新し、選択されたレコードを (リクエスト URL に渡された検証済みレコード ID を使用して) 取得してから、そのレコードを AdminEditView に渡すようにします (リスト 27)。

リスト 27. Listing/AdminEditAction の定義
<?php
class Listing_AdminEditAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Input';
  }
  
  public function executeRead(AgaviRequestDataHolder $rd)
  {
    try {
      // get record ID
      $id = $rd->getParameter('id');
      
      // get record
      $q = Doctrine_Query::create()
            ->from('Listing l')
            ->leftJoin('l.Manufacturer m')
            ->leftJoin('l.Country c')
            ->where('l.RecordID = ?', $id);
      $result = $q->fetchArray();
      
      // if record exists, show input form
      // else generate 404 error page
      if (count($result) == 1) {
        $this->setAttribute('listing', $result[0]);
        return 'Input';
      } else {        
        return 'Redirect404';
      }
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  } 
}
?>

リクエストされた ID と一致するレコードがない場合、クライアントは AdminEditRedirect404View にリダイレクトされます。このビューは単に、「Page not found」エラーを表示する Agavi のデフォルト Error404 ビューにクライアントをリダイレクトするだけにすぎません。

リクエストされた ID と一致するレコードが見つかった場合には、AdminEditInputView がそのレコードを処理して、入力フォームのフィールドと一致するキーを持つ連想配列に変換します。各フィールドに対応する値を事前に入力するには、この配列を引数として FormPopulationFilter を呼び出せばよいだけです (リスト 28)。

リスト 28. Listing/AdminEditInputView の定義
<?php
class Listing_AdminEditInputView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd, 'admin');

    $this->setInputViewAttributes();    
    
    // pre-populate form
    if ($this->getAttribute('listing')) {      
      $pre = new AgaviParameterHolder();
      
      $record = $this->getAttribute('listing');        
      foreach ($record as $k => $v) {
        $pre->setParameter("$k", $v);          
      }
      
      // special modification: VehicleAccessoryBit
      $allBits = array(1,2,4,8,16,32,64);    
      $selectedBits = array();
      foreach ($allBits as $bit) {
        ($record['VehicleAccessoryBit'] & $bit) ? $selectedBits[] = $bit : null; 
      }
      $pre->setParameter("VehicleAccessoryBit", $selectedBits);
         
      // special modification: VehicleCertificationDate
      $pre->setParameter("VehicleCertificationDate_mm", 
       date('n', strtotime($record['VehicleCertificationDate'])));
      $pre->setParameter("VehicleCertificationDate_yyyy", 
       date('Y', strtotime($record['VehicleCertificationDate'])));
      
      // special modification: DisplayUntilDate
      $pre->setParameter("DisplayUntilDate_dd", date('j', 
       strtotime($record['DisplayUntilDate'])));
      $pre->setParameter("DisplayUntilDate_mm", date('n', 
       strtotime($record['DisplayUntilDate'])));
      $pre->setParameter("DisplayUntilDate_yyyy", date('Y', 
       strtotime($record['DisplayUntilDate'])));
      
      // populate form
      $this->getContext()->getRequest()->setAttribute('populate', $pre, 
       'org.agavi.filter.FormPopulationFilter');
    }    
  }
}
?>

念のため言っておくと、AdminEditInput テンプレートには、リスト 3 に記載したフォームに 2 つの特殊な管理フィールドを追加したフォームが含まれています。この 2 つのフィールドとは、1 つはステータスを表示するためのフィールド、もう 1 つは有効期限を表示するためのフィールドです。リスト 29 に、追加されている内容を記載します。

リスト 29. Listing/AdminEditInput テンプレート
<h3>Edit Listing</h3>
<form action="<?php echo $ro->gen(null); ?>" method="post">
  ...
<fieldset>  
    <legend>Listing Status</legend>
    <label for="DisplayStatus" class="required">Display status:</label>
    <select id="DisplayStatus" name="DisplayStatus" 
     onChange="javascript:handleInputDisplayOnSelect('DisplayStatus', 
     'divDisplayUntilDate', new Array('1'))">
      <option value="0">Hidden</option>
      <option value="1">Visible</option>
    </select>
    <p/>
    <div id="divDisplayUntilDate" style="display:none">
      <label for="DisplayUntilDate" class="required">Display until:</label>
      <select id="DisplayUntilDate_dd" name="DisplayUntilDate_dd">
        <?php for ($x=1; $x<=31; $x++): ?>
        <?php echo "<option value=\"$x\">$x</option>"; ?>
        <?php endfor; ?>
      </select>
      <select id="DisplayUntilDate_mm" name="DisplayUntilDate_mm">
        <?php for ($x=1; $x<=12; $x++): ?>
        <?php echo "<option value=\"$x\">" . 
         date("F", mktime(null, null, null, $x, 1)) . "</option>"; ?>
        <?php endfor; ?>
      </select>
      <select id="DisplayUntilDate_yyyy" name="DisplayUntilDate_yyyy">
        <?php for ($x=date('Y'); $x<=(date('Y')+5); $x++): ?>
        <?php echo "<option value=\"$x\">$x</option>"; ?>
        <?php endfor; ?>
      </select>
    </div>
  </fieldset
  ...
</form>

上記に対応する検証ルールはリスト 30 のとおりです。

リスト 30. Listing/AdminEditAction バリデーター
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Listing/config/validators.xml"
>
  <ae:configuration>

    <validators>
    ...       
      <validator class="number">
        <arguments>
          <argument>DisplayStatus</argument>
        </arguments>
        <errors>
          <error>ERROR: Display status is missing or invalid</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
          <ae:parameter name="min">0</ae:parameter>
          <ae:parameter name="max">1</ae:parameter>
        </ae:parameters>
      </validator>                      
      
      <validator class="datetime">
        <arguments>
          <argument name="AgaviDateDefinitions::DATE">DisplayUntilDate_dd
          </argument>
          <argument name="AgaviDateDefinitions::MONTH">DisplayUntilDate_mm
          </argument>
          <argument name="AgaviDateDefinitions::YEAR">DisplayUntilDate_yyyy
          </argument>
        </arguments>
        <errors>
          <error>ERROR: Display expiry date is missing or invalid</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
          <ae:parameter name="check">true</ae:parameter>
          <ae:parameter name="export">DisplayUntilDate</ae:parameter>
          <ae:parameter name="cast_to">
            <ae:parameters>
                <ae:parameter name="type">date</ae:parameter>
                <ae:parameter name="format">yyyy-MM-dd</ae:parameter>
            </ae:parameters>
          </ae:parameter>
        </ae:parameters>
      </validator>                
            
    </validators>
    
  </ae:configuration>
</ae:configurations>

事前に入力された入力フィールドは、図 5 のように表示されます。

図 5. フィールドが事前に入力された状態の Web フォーム
フィールドが事前に入力された状態の Web フォーム
フィールドが事前に入力された状態の Web フォーム

このフォームが再び AdminEditAction に送信されると、executeWrite() メソッドが Listing オブジェクトを作成して送信された値を取り込みます。続いて save() メソッドを呼び出して、対応する UPDATE クエリーを作成し、実行します (リスト 31)。

リスト 31. Listing/AdminEditAction の定義
<?php
class Listing_AdminEditAction extends WASPListingBaseAction
{
  ...
  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    try {
      // initialize object
      $listing = new Listing();
      $listing->assignIdentifier($rd->getParameter('id'));
      
      // populate with validated input
      $listing->fromArray($rd->getParameters());      
      $listing->VehicleAccessoryBit = 
       array_sum($rd->getParameter('VehicleAccessoryBit'));
      
      // save updated record
      $listing->save();
      return 'Success';
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';     
    }
  }     
}
?>

リスト 31 では assignIdentifier() を呼び出していることに注目してください。レコードの主キーを設定するために使用されるこのメソッドは、Doctrine に対し、処理中のレコードがデータベースの既存のレコードであることを通知する役割を果たします。そのため、Doctrine は INSERT クエリーではなく、UPDATE クエリーを使用するというわけです。

以上の作業で、WASP 管理者はレコードを表示し、編集し、削除できるようになりました。別の言い方をすれば、管理モジュールは完全に機能するようになったということです。しかし、このモジュールにはセキュリティー対策が講じられていないため、どの WASP ユーザーでもアクセスできてしまいます。次のセクションでは、この点を修正します。

ユーザー認証の追加

Agavi には完全な機能を備えたセキュリティー・フレームワークが用意されています。このフレームワークは、単純なパスワードによる認証から、複雑なロール・ベースのアクセス制御 (RBAC) までサポートしています。しかも、カスタム要件に合わせて簡単に拡張することもできます。このサンプル・アプリケーションの場合は、パスワードによる認証を使えば、十分目的を果たせます。つまり、管理者が管理モジュールにアクセスするには、有効なパスワードを入力しなければならないように設定するのです。

アクセス制御は、アクションの isSecure() メソッドを使ってアクションごとに指定します。このメソッドが true を返すと、Agavi は現行ユーザーが認証済みであるかどうかをチェックし、認証済みでない場合はアプリケーションのデフォルト・ログイン・アクション (通常は Default/LoginAction) へ転送します。LoginAction はユーザーのクレデンシャルを取得して検証し、以降のリクエストに対してユーザーを認証します。

以下のステップは、WASP 管理モジュールをセキュアにする方法です。

ステップ 1: ユーザー・データベースおよびモデルを作成する

まずは、以下のように管理ユーザーの名前とパスワードを保持する新しい MySQL テーブルを作成するところから始めます。

mysql> CREATE TABLE IF NOT EXISTS `user` (
    -> RecordID int(11) NOT NULL AUTO_INCREMENT,
    -> Username varchar(25) CHARACTER SET utf8 NOT NULL,
    -> `Password` text CHARACTER SET utf8 NOT NULL,
    -> PRIMARY KEY (RecordID),
    -> UNIQUE KEY Username (Username)
    -> ) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
Query OK, 0 rows affected (0.13 sec)

作業を進められるように、このテーブルにアカウントをいくつか追加します。

mysql> INSERT INTO user (Username, Password) VALUES('simon', PASSWORD('says'));
Query OK, 1 row affected (0.08 sec)

mysql> INSERT INTO user (Username, Password) VALUES('marco', PASSWORD('polo'));
Query OK, 1 row affected (0.08 sec)

次に、連載第 2 回の「Agavi と Doctrine の統合」で説明したプロセスに従って、Doctrine を使ってこのテーブル用の User モデルを生成します。出来上がったクラスは、$WASP_ROOT/app/lib/doctrine/ ディレクトリーに追加してください。

shell> php doctrine-gen.php
shell> cd /usr/local/apache/htdocs/wasp/app/lib
shell> cp /tmp/models/User.php doctrine/
shell> cp /tmp/models/generated/BaseUser.php doctrine/

ステップ 2: プレースホルダー・クラスを作成する

どの Agavi アプリケーションにも、LoginAction はデフォルトで組み込まれていますが、LogoutAction は組み込まれていません。そこで、Agavi ビルド・スクリプトを起動してこのアクションを追加します。

shell> agavi action-wizard
Module name: Default
Action name: Logout
Space-separated list of views to create for Save [Success]: Error Success

以下のように、LoginInputView、LoginSuccessView、および LoginErrorView のテンプレートを生成する必要もあります。

shell> agavi template-create
Module name: Default
Template name: LoginSuccess

shell> agavi template-create
Module name: Default
Template name: LoginInput

shell> agavi template-create
Module name: Default
Template name: LoginError

ステップ 3: ルートを定義する

LoginAction と LogoutAction のルートをアプリケーションのルーティング・テーブルに追加します (リスト 32)。

リスト 32. Default/LoginAction ルートの定義
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
  <ae:configuration>
    <routes>
      ...  
      <!-- action for admin login page "/admin/login" -->
      <route name="admin.login" pattern="^/admin/login$" 
       module="Default" action="Login" />
      
      <!-- action for admin logout pages "/admin/logout" -->
      <route name="admin.logout" pattern="^/admin/logout$" 
       module="Default" action="Logout" />
                                  
    </routes>
  </ae:configuration>
</ae:configurations>

ステップ 4: アクションのコードを作成する

LoginAction には、ユーザーが送信したクレデンシャルを読み取ってデータベースに対して照合するコードを追加します (リスト 33)。クレデンシャルが有効であれば、このアクションは setAuthenticated() メソッドを使用して認証フラグを設定し、LoginSuccess ビューをレンダリングします。無効な場合は、LoginError ビューを返します。

リスト 33. Default/LoginAction の定義
<?php
class Default_LoginAction extends WASPDefaultBaseAction
{

  public function getDefaultViewName()
  {
    return 'Input';
  }
  
  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    try {
      // get input parameters
      $u = $rd->getParameter('username');
      $p = $rd->getParameter('password');
      
      // check user credentials
      $q = Doctrine_Query::create()
            ->from('User u')
            ->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p));
      $result = $q->fetchArray();
        
      // set authentication flag if valid
      if (count($result) == 1) {
        $this->getContext()->getUser()->setAuthenticated(true);
        return 'Success';
      } else {
        return 'Error';
      }
    } catch (Exception $e) {
        return 'Error';        
    }
  }
}
?>

LogoutAction はこれとは逆に、ユーザー認証フラグの設定を解除し、ユーザー・セッションを終了します。このコードはリスト 34 のとおりです。

リスト 34. Default/LogoutAction の定義
<?php
class Default_LogoutAction extends WASPDefaultBaseAction
{
  public function executeRead(AgaviRequestDataHolder $rd)
  {
    try {
      $this->getContext()->getUser()->setAuthenticated(false);
      return 'Success';
    } catch (Exception $e) {
      return 'Error';     
    }
  } 
}
?>

ステップ 5: ビューのコードを作成する

LoginAction と LogoutAction に属する各種ビューのうち、LoginInputView および LoginSuccessView には特に注意が必要です。

LoginInputView が生成するログイン・フォームは、アクセスが制限されたアクションにユーザーがアクセスしようとすると表示されます。また、このビューは最初にユーザーがリクエストした URL を保存しておき、ユーザーのログインが成功すると、その URL にユーザーをリダイレクトします。この振る舞いを実現する簡単な方法は、Agavi cookbook (「参考文献」でリンクを参照) によると、最初にユーザーがリクエストした URL を実行コンテキストに保存することです (リスト 35)。

リスト 35. Default/LoginInputView の定義
<?php
class Default_LoginInputView extends WASPDefaultBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    // get referrer URL and save it
    if($this->getContainer()->hasAttributeNamespace(
     'org.agavi.controller.forwards.login')) {
      $this->getContext()->getUser()->setAttribute('redirect', 
       $this->getContext()->getRequest()->getUrl(), 'org.agavi.WASP.login');
    } else {
      $this->getContext()->getUser()->removeAttribute('redirect', 
       'org.agavi.WASP.login');
    }   
    $this->setupHtml($rd, 'admin');
  }
}
?>

このビューによって生成される LoginInput テンプレートはかなり基本的なもので、2 つのフィールドを持つ Web フォームです。このコードをリスト 36 に記載し、図 6 にフォームを示します。

リスト 36. Default/ContactInput テンプレート
<h3>Log In</h3>
<form action="<?php echo $ro->gen('admin.login'); ?>" method="post">
  <label for="username" class="required">Username:</label>
  <input id="username" type="text" name="username" style="width:150px" />
  <p/>
  <label for="password" class="required">Password:</label>
  <input id="password" type="password" name="password" style="width:150px" />
  <p/>
  <input type="submit" name="submit" class="submit" value="Log In" />
</form>
図 6. ログイン・フォーム
ログイン・フォーム
ログイン・フォーム

上記のフォームの入力検証ルールをリスト 37 に記載します。

リスト 37. Default/LoginAction バリデーター
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Default/config/validators.xml"
>
  <ae:configuration>
    
    <validators method="write">
      <validator class="string">
        <arguments>
          <argument>username</argument>
        </arguments>
        <errors>
          <error for="required">ERROR: Username is missing</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
        </ae:parameters>
      </validator>
      
      <validator class="string">
        <arguments>
          <argument>password</argument>
        </arguments>
        <errors>
          <error for="required">ERROR: Password is missing</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
        </ae:parameters>
      </validator>
    </validators>
    
  </ae:configuration>
</ae:configurations>

ログインに成功すると、LoginSuccessView は最初にユーザーが送信した URL リクエストを取得し、クライアントをその URL にリダイレクトします (リスト 38)。

リスト 38. Default/LoginSuccessView の定義
<?php
class Default_LoginSuccessView extends WASPDefaultBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    // get original request URL
    // redirect 
    if($this->getContext()->getUser()->hasAttribute('redirect', 
     'org.agavi.WASP.login')) {
      $this->getResponse()->setRedirect($this->getContext()->
       getUser()->removeAttribute('redirect', 'org.agavi.WASP.login'));
      return true;
    }
    $this->setupHtml($rd, 'admin');
  }
}
?>

ステップ 6: セキュア・アクションを定義する

作業の完了は間近です。後は、認証を必要とするアクションを定義すればよいだけとなりました。AdminIndexAction、AdminDeleteAction、AdminEditAction クラスを開き、そのそれぞれに、true を返す isSecure() メソッドを追加してください。リスト 39 に、一例を示します。

リスト 39. セキュア・アクション
<?php
class Listing_AdminEditAction extends WASPListingBaseAction
{
  ...  
  final public function isSecure()
  {
    return true;
  }     
}
?>

これで、ユーザーが管理ルートのいずれか (例えば http://wasp.localhost/admin/listing/index の要約ページなど) にアクセスしようとすると、Agavi はログイン・フォームを表示するページにユーザーをリダイレクトします。そして、有効なクレデンシャルを入力した場合に限り、リクエストした URL を表示することになります。ぜひ、ご自分で試してみてください。

まとめ

第 3 回目の記事は、以上で終わりです。後半のセクションで本格的な管理モジュールを追加し、データベース内にある自動車のリストをユーザーが追加、編集、削除できるようにする Web ベースのインターフェースを提供したことにより、WASP サンプル・アプリケーションはかなり充実してきました。それだけではありません。今回は、管理モジュールに別のレイアウトを定義する方法、Agavi のユーザー認証およびセキュリティー・モデルの基本についても説明しました。

この記事で実装したコードはすべて、「ダウンロード」セクションに用意してあります。コードをぜひ入手して、いろいろと試してみてください。実際に新しい機能を追加してみるのもいいかもしれません。何も壊れることはありませんので、いい勉強になるはずです。それでは、次回の記事まで、いろいろ試して楽しんでください!


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=XML, Open source, Web development
ArticleID=426859
ArticleTitle=Agavi による MVC プログラミング入門: 第 3 回 Agavi を使って、認証機能と管理機能を追加する
publish-date=08252009