Jobeet - 16日目: Webサービス
ひさびさの和訳公開です。公式ページに日本語訳がすでにあることに驚きながら訳しましたw
しかし、ちょっと公式サイトの方は直訳で読みづらい感もあったのでこっちは適所を意訳してみました。
あと訳中に出てくる「アフィリエイト」はおそらく「求人を出す会社」のことを指しています。適当な日本語が無かったのでそのままカタカナにしました。
Day 16: Web Services (1_2) - Symfony
Jobeetにフィードを追加したので、仕事を探す人はリアルタイムで新しい求人を見つけれるようになっています。
一方、求人を投稿するときはできるだけ見つけやすいようにしておきたいと考えます。もし求人がたくさんの小さなWebサイトで見れるのであれば、適任者を見つける機会がより増えることになります。それらはロングテールの力です。今日開発するWebサービスのおかげでアフィリエイトも自分のWebサイト上で最新の求人を投稿できるようになるでしょう。
アフィリエイト
2日目の必要要件によると、下記の
「Story F7: アフィリエイトはアクティブな求人リストを検索します。」
Fixtures
アフィリエイト用の新しいFixtureファイルを作りましょう。
# data/fixtures/030_affiliates.yml JobeetAffiliate: sensio_labs: url: http://www.sensio-labs.com/ email: fabien.potencier@example.com is_active: true token: sensio_labs jobeet_category_affiliates: [programming] symfony: url: http://www.symfony-project.org/ email: fabien.potencier@example.org is_active: false token: symfony jobeet_category_affiliates: [design, programming]
多対多のリレーションの中間テーブルのレコード生成は「s」を加えた中間テーブルのキーの配列を定義するようにシンプルです。配列のコンテンツはFixtureファイル内で定義されたオブジェクト名です。異なるファイルからオブジェクトへリンクできますが、名前は最初に定義しなければなりません。
Fixtureファイルではトークンはテスト用に単純にハードコーディングされていますが、実際にユーザがアカウントの申請するときはトークンは生成される必要があります。
<?php // lib/model/JobeetAffiliate.php class JobeetAffiliate extends BaseJobeetAffiliate { public function save(PropelPDO $con = null) { if (!$this->getToken()) { $this->setToken(sha1($this->getEmail().rand(11111, 99999))); } return parent::save($con); } // ... }
データのリロードをします。
$ php symfony propel:data-load
求人のWebサービス
いつものように、新しいリソースを生成したとき、最初にURLを定義することは良い習慣です。
# apps/frontend/config/routing.yml api_jobs: url: /api/:token/jobs.:sf_format class: sfPropelRoute param: { module: api, action: list } options: { model: JobeetJob, type: list, method: getForToken } requirements: sf_format: (?:xml|json|yaml)
このルート定義は、特別なsf_format変数がURLの終わりにあります、有効な値はxml, json, yamlです。
getForToken()メソッドはアクションがルートに関連しているオブジェクトのコレクションを取得するときに呼ばれます。アフィリエイトが有効であることを確認する必要があるので、ルートのデフォルトの振る舞いをオーバーライドする必要があります。
<?php // lib/model/JobeetJobPeer.php class JobeetJobPeer extends BaseJobeetJobPeer { static public function getForToken(array $parameters) { $affiliate = JobeetAffiliatePeer::getByToken($parameters['token']); if (!$affiliate || !$affiliate->getIsActive()) { throw new sfError404Exception(sprintf('Affiliate with token "%s" does not exist or is not activated.', $parameters['token'])); } return $affiliate->getActiveJobs(); } // ... }
もしトークンがデータベースに存在しないならば、sfError404Exception例外を投げます。この例外クラスは404レスポンスを自動で変換します。このことはモデルクラスから404ページを生成する最もシンプルな方法です。
getForToken()メソッドは今作った2つの新しいメソッドで使われています。
最初に、getByToken()メソッドは取得したトークンでアフィリエイトのオブジェクトを生成します。
<?php // lib/model/JobeetAffiliatePeer.php class JobeetAffiliatePeer extends BaseJobeetAffiliatePeer { static public function getByToken($token) { $criteria = new Criteria(); $criteria->add(self::TOKEN, $token); return self::doSelectOne($criteria); } }
それから、getActiveJobs()メソッドはアフィリエイトによって選択されたカテゴリーの現在の有効な求人のリストを返します。
<?php // lib/model/JobeetAffiliate.php class JobeetAffiliate extends BaseJobeetAffiliate { public function getActiveJobs() { $cas = $this->getJobeetCategoryAffiliates(); $categories = array(); foreach ($cas as $ca) { $categories[] = $ca->getCategoryId(); } $criteria = new Criteria(); $criteria->add(JobeetJobPeer::CATEGORY_ID, $categories, Criteria::IN); JobeetJobPeer::addActiveJobsCriteria($criteria); return JobeetJobPeer::doSelect($criteria); } // ... }
最後のステップではapiアクションとテンプレートを生成します。モジュール生成のブートストラップは generate:module タスクです。
$ php symfony generate:module frontend api
デフォルトのindexアクションを使わないので、アクションクラスから削除し、関連するテンプレートであるindexSuccess.phpも削除します。
アクション
全てのフォーマットは同じリストアクションで共有します。
<?php // apps/frontend/modules/api/actions/actions.class.php public function executeList(sfWebRequest $request) { $this->jobs = array(); foreach ($this->getRoute()->getObjects() as $job) { $this->jobs[$this->generateUrl('job_show_user', $job, true)] = $job->asArray($request->getHost()); } }
テンプレートへJobeetJobオブジェクトの配列を渡す代わりに、文字列の配列を渡します。同じアクションで3つの異なるテンプレートを持つので、値を処理するロジックはJobeetJob::asArray()メソッドが行います。
<?php // lib/model/JobeetJob.php class JobeetJob extends BaseJobeetJob { public function asArray($host) { return array( 'category' => $this->getJobeetCategory()->getName(), 'type' => $this->getType(), 'company' => $this->getCompany(), 'logo' => $this->getLogo() ? 'http://'.$host.'/uploads/jobs/'.$this->getLogo() : null, 'url' => $this->getUrl(), 'position' => $this->getPosition(), 'location' => $this->getLocation(), 'description' => $this->getDescription(), 'how_to_apply' => $this->getHowToApply(), 'expires_at' => $this->getCreatedAt('c'), ); } // ... }
xmlフォーマット
XMLフォーマットのサポートはテンプレート生成と同じくらい簡単です。
<?php <!-- apps/frontend/modules/api/templates/listSuccess.xml.php --> <?xml version="1.0" encoding="utf-8"?> <jobs> <?php foreach ($jobs as $url => $job): ?> <job url="<?php echo $url ?>"> <?php foreach ($job as $key => $value): ?> <<?php echo $key ?>><?php echo $value ?></<?php echo $key ?>> <?php endforeach; ?> </job> <?php endforeach; ?> </jobs>
jsonフォーマット
JSONフォーマットのサポートもXMLと同様です。
<?php <!-- apps/frontend/modules/api/templates/listSuccess.json.php --> [ <?php $nb = count($jobs); $i = 0; foreach ($jobs as $url => $job): ++$i ?> { "url": "<?php echo $url ?>", <?php $nb1 = count($job); $j = 0; foreach ($job as $key => $value): ++$j ?> "<?php echo $key ?>": <?php echo json_encode($value).($nb1 == $j ? '' : ',') ?> <?php endforeach; ?> }<?php echo $nb == $i ? '' : ',' ?> <?php endforeach; ?> ]
yamlフォーマット
ビルトインされたフォーマット向けにコンテンツタイプを変更したり、レイアウトを無効にするようにsymfonyはバックグラウンドでいろいろな設定をしています。
YAMLフォーマットはビルトインされたリクエスト用のフォーマットでは無いので、アクション内でレスポンスコンテントタイプを変更して、レイアウトを無効にできます。
<?php class apiActions extends sfActions { public function executeList(sfWebRequest $request) { $this->jobs = array(); foreach ($this->getRoute()->getObjects() as $job) { $this->jobs[$this->generateUrl('job_show_user', $job, true)] = $job->asArray($request->getHost()); } switch ($request->getRequestFormat()) { case 'yaml': $this->setLayout(false); $this->getResponse()->setContentType('text/yaml'); break; } } }
アクション内で、setLayout()メソッドはデフォルトレイアウトを変更し、falseにセットされるとレイアウトを無効にします。
YAMLのテンプレートは下記のようになります。
<?php <!-- apps/frontend/modules/api/templates/listSuccess.yaml.php --> <?php foreach ($jobs as $url => $job): ?> - url: <?php echo $url ?> <?php foreach ($job as $key => $value): ?> <?php echo $key ?>: <?php echo sfYaml::dump($value) ?> <?php endforeach; ?> <?php endforeach; ?>
もし無効なトークンを使ってWebサービスをコールしようとすると、XMLフォーマットの場合404のXMLページが表示され、JSONフォーマットでは404のJSONページを表示します。しかしYAMLフォーマットのエラーページをsymfonyは表示ができません。
フォーマットを作る時はいつでもカスタムエラーテンプレートを作る必要があります。作成されたテンプレートは404エラーページや全ての例外エラーページとして使われます。
本番環境と開発環境で異なる例外になるようにするには2ファイルが必要となります。(開発環境用にはconfig/error/exception.yaml.php、本番環境用にはconfig/error/error.yaml.phpが必要です)
<?php // config/error/exception.yaml.php <?php echo sfYaml::dump(array( 'error' => array( 'code' => $code, 'message' => $message, 'debug' => array( 'name' => $name, 'message' => $message, 'traces' => $traces, ), )), 4) ?> // config/error/error.yaml.php <?php echo sfYaml::dump(array( 'error' => array( 'code' => $code, 'message' => $message, ))) ?>
試す前にYAMLフォーマットのレイアウトファイルを作成する必要があります。
<?php // apps/frontend/templates/layout.yaml.php <?php echo $sf_content ?>
ビルトインされている404エラーや例外エラーのテンプレートをオーバーライドするには、 config/errorディレクトリにファイルを作るだけの簡単な処理です。
Webサービステスト
Webサービスをテストするため、data/fixturesディレクトリからtest/fixturesディレクトリにアフィリエイトのfixtureをコピーし、自動生成されるapiActionsTest.phpのコンテンツを下記コードに置き換えます。
<?php // test/functional/frontend/apiActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new JobeetTestFunctional(new sfBrowser()); $browser->loadData(); $browser-> info('1 - Web service security')-> info(' 1.1 - A token is needed to access the service')-> get('/api/foo/jobs.xml')-> with('response')->isStatusCode(404)-> info(' 1.2 - An inactive account cannot access the web service')-> get('/api/symfony/jobs.xml')-> with('response')->isStatusCode(404)-> info('2 - The jobs returned are limited to the categories configured for the affiliate')-> get('/api/sensio_labs/jobs.xml')-> with('request')->isFormat('xml')-> with('response')->checkElement('job', 32)-> info('3 - The web service supports the JSON format')-> get('/api/sensio_labs/jobs.json')-> with('request')->isFormat('json')-> with('response')->contains('"category": "Programming"')-> info('4 - The web service supports the YAML format')-> get('/api/sensio_labs/jobs.yaml')-> with('response')->begin()-> isHeader('content-type', 'text/yaml; charset=utf-8')-> contains('category: Programming')-> end() ;
このテスト内で2つの新しいメソッドがあることに気づきます。
- isFormat()
- リクエストされたフォーマットのテスト
- contains()
- HTMLでないフォーマット用にレスポンスが期待されるようなテキストであるかどうかチェック
アフィリエイトアプリケーションフォーム
Webサービスがすでに使えるようになったので、アフィリエイト向けのアカウント生成フォームを生成していきましょう。再度、アプリケーションへ新しい要素を追加する昔ながらの処理を記述していきます。
ルーティング
予想している通り、ルートは最初に生成していきます。
# apps/frontend/config/routing.yml affiliate: class: sfPropelRouteCollection options: model: JobeetAffiliate actions: [new, create] object_actions: { wait: get }
上記コードは新しい設定オプションであるactionsが設定された典型的なPropelコレクションルートです。ルートによって定義される7つのデフォルトアクションを必要しないのであれば、actionsオプションでマッチしたnewとcreateアクションだけを使うようルートに指示します。追加されているwaitルートはアカウントへまもなくアフィリエイトになりそうなアフィリエイトのフィードバックを送信するのに使われます。
ブートストラップ
古典的な2ステップ目はモジュールを生成することです。
$ php symfony propel:generate-module frontend affiliate JobeetAffiliate --non-verbose-templates
テンプレート
propel:generate-moduleタスクはデフォルトで生成される7アクションとそれに対応する各テンプレートを作ります。templatesディレクトリ内の_form.phpとnewSuccess.phpファイルを除いた全てのファイルを削除します。キープしている2ファイルは下記コードに置き換えます。
<?php <!-- apps/frontend/modules/affiliate/templates/newSuccess.php --> <?php use_stylesheet('job.css') ?> <h1>Become an Affiliate</h1> <?php include_partial('form', array('form' => $form)) ?> <!-- apps/frontend/modules/affiliate/templates/_form.php --> <?php include_stylesheets_for_form($form) ?> <?php include_javascripts_for_form($form) ?> <?php echo form_tag_for($form, 'affiliate') ?> <table id="job_form"> <tfoot> <tr> <td colspan="2"> <input type="submit" value="Submit" /> </td> </tr> </tfoot> <tbody> <?php echo $form ?> </tbody> </table> </form>
waitSuccess.phpテンプレートを生成します。
<?php <!-- apps/frontend/modules/affiliate/templates/waitSuccess.php --> <h1>Your affiliate account has been created</h1> <div style="padding: 20px"> Thank you! You will receive an email with your affiliate token as soon as your account will be activated. </div>
最後にaffiliateモジュールを指し示すようにフッターリンクを変更します。
アクション
同じことになりますが、生成フォームを使うだけであるので、actions.class.phpファイルを開いてexecuteNew()、executeCreate()、processForm()メソッド以外の全てのメソッドを削除します。
processForm()アクション向けにリダイレクト先をwaitアクションへ変更します。
<?php // apps/frontend/modules/affiliate/actions/actions.class.php $this->redirect($this->generateUrl('affiliate_wait', $jobeet_affiliate));
waitアクションはテンプレートにパラメータを渡す必要がないためシンプルです。
<?php // apps/frontend/modules/affiliate/actions/actions.class.php public function executeWait() { }
affiliateモジュールはトークンが選択できず、アカウントをすぐにアクティブにすることもできません。フォームをカスタマイズするためにJobeetAffiliateFormファイルを開きます。
<?php // lib/form/doctrine/JobeetAffiliateForm.class.php class JobeetAffiliateForm extends BaseJobeetAffiliateForm { public function configure() { unset($this['is_active'], $this['token'], $this['created_at']); $this->widgetSchema['jobeet_category_affiliate_list']->setOption('expanded', true); $this->widgetSchema['jobeet_category_affiliate_list']->setLabel('Categories'); $this->validatorSchema['jobeet_category_affiliate_list']->setOption('required', true); $this->widgetSchema['url']->setLabel('Your website URL'); $this->widgetSchema['url']->setAttribute('size', 50); $this->widgetSchema['email']->setAttribute('size', 50); $this->validatorSchema['email'] = new sfValidatorEmail(array('required' => true)); } }
フォームフレームワークは他のカラムとの多対多のリレーションをサポートしています。デフォルトではそのようなリレーションはsfWidgetFormChoiceウィジェットのおかげでドロップダウンボックスといて表示されます。10日目で見たように、拡張オプションを使うことでタグ表示部分を変更しました。
EメールやURLはinputタグのデフォルトサイズよりかなり長くなる傾向があるため、デフォルトのHTML属性はsetAttribute()メソッドを使うことで設定できます。
Test
最後のステップは新しい要素向けの機能テストを記述していきます。
affiliateモジュールによって自動で作られたテストを下記コードに置き換えます。
<?php // test/functional/frontend/affiliateActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new JobeetTestFunctional(new sfBrowser()); $browser->loadData(); $browser-> info('1 - An affiliate can create an account')-> get('/affiliate/new')-> click('Submit', array('jobeet_affiliate' => array( 'url' => 'http://www.example.com/', 'email' => 'foo@example.com', 'jobeet_category_affiliate_list' => array($browser->getProgrammingCategory()->getId()), )))-> isRedirected()-> followRedirect()-> with('response')->checkElement('#content h1', 'Your affiliate account has been created')-> info('2 - An affiliate must at least select one category')-> get('/affiliate/new')-> click('Submit', array('jobeet_affiliate' => array( 'url' => 'http://www.example.com/', 'email' => 'foo@example.com', )))-> with('form')->isError('jobeet_category_affiliate_list') ;
チェックボックスの選択動作をシミュレートするため、チェックする識別子の配列を渡します。タスクを単純化するため、JobeetTestFunctionalクラスに新しくgetProgrammingCategory()メソッドを作ります。
<?php // lib/model/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function getProgrammingCategory() { $criteria = new Criteria(); $criteria->add(JobeetCategoryPeer::SLUG, 'programming'); return JobeetCategoryPeer::doSelectOne($criteria); } // ... }
しかしgetMostRecentProgrammingJob()メソッド内に上記コードが存在しているので、リファクタリングするためJobeetCategoryPeerクラスにgetForSlug()メソッドを作ります。
<?php // lib/model/JobeetCategoryPeer.php static public function getForSlug($slug) { $criteria = new Criteria(); $criteria->add(self::SLUG, $slug); return self::doSelectOne($criteria); }
そして、JobeetTestFunctionalクラスで上記コードが繰り返されている箇所が置き換えられました。
アフィリエイトバックエンド
バックエンドでは、affiliateモジュールは管理者によってアクティブにされた会社を登録しなければなりません。
$ php symfony propel:generate-admin backend JobeetAffiliate --module=affiliate
新たに作られたモジュールにアクセスするため、アクティブにされる必要があるアフィリエイト数が表示されたリンクをメインメニューに追加します。
<!-- apps/backend/templates/layout.php --> <li> <a href="<?php echo url_for('@jobeet_affiliate') ?>"> Affiliates - <strong><?php echo JobeetAffiliatePeer::countToBeActivated() ?></strong> </a> </li> <?php // lib/model/JobeetAffiliatePeer.php class JobeetAffiliatePeer extends BaseJobeetAffiliatePeer { static public function countToBeActivated() { $criteria = new Criteria(); $criteria->add(self::IS_ACTIVE, 0); return self::doCount($criteria); } // ... }
バックエンドに必要なアクションはアカウントを有効にしたり無効にするだけであるので、インターフェースを簡単にするためデフォルトジェネレータのconfigセクションを変更し、リストビューから直接アクティブなアカウントへのリンクを追加します。
# apps/backend/modules/affiliate/config/generator.yml config: fields: is_active: { label: Active? } list: title: Affiliate Management display: [is_active, url, email, token] sort: [is_active] object_actions: activate: ~ deactivate: ~ batch_actions: activate: ~ deactivate: ~ actions: {} filter: display: [url, email, is_active]
管理者をより生産的にするため、デフォルトフィルタをアクティブなアフィリエイトだけを表示するように変更します。
<?php // apps/backend/modules/affiliate/lib/affiliateGeneratorConfiguration.class.php class affiliateGeneratorConfiguration extends BaseAffiliateGeneratorConfiguration { public function getFilterDefaults() { return array('is_active' => '0'); } }
それ以外に書くコードはactiveとdeactiveアクションです。
<?php // apps/backend/modules/affiliate/actions/actions.class.php class affiliateActions extends autoAffiliateActions { public function executeListActivate() { $this->getRoute()->getObject()->activate(); $this->redirect('@jobeet_affiliate'); } public function executeListDeactivate() { $this->getRoute()->getObject()->deactivate(); $this->redirect('@jobeet_affiliate'); } public function executeBatchActivate(sfWebRequest $request) { $affiliates = JobeetAffiliatePeer::retrieveByPks($request->getParameter('ids')); foreach ($affiliates as $affiliate) { $affiliate->activate(); } $this->redirect('@jobeet_affiliate'); } public function executeBatchDeactivate(sfWebRequest $request) { $affiliates = JobeetAffiliatePeer::retrieveByPks($request->getParameter('ids')); foreach ($affiliates as $affiliate) { $affiliate->deactivate(); } $this->redirect('@jobeet_affiliate'); } } // lib/model/JobeetAffiliate.php class JobeetAffiliate extends BaseJobeetAffiliate { public function activate() { $this->setIsActive(true); return $this->save(); } public function deactivate() { $this->setIsActive(false); return $this->save(); } // ... }
Eメールの送信
管理者によってアフィリエイトアカウントがアクティブにされたときはいつでも、アフィリエイト確認のメールと専用のトークンを送るべきです。
PHPはSwiftMailerやZend_Mail、ezcMailのようなメール送信のためのよいライブラリがいくつもあります。現在、他のZend Frameworkライブラリを利用しているので今回はZend_Mailを使ってみましょう。
Zend Frameworkのインストールと設定
Zend MailライブラリはZend Frameworkの一部です。Zend Framework全ては必要ないので、必要な部分だけを lib/vender ディレクトリにsymfonyと平行してインストールします。
下記の説明はZend Frameworkの1.8.0バージョンでテストされています。
下記に示すファイルとディレクトリ以外を削除してディレクトリをきれいにできます。
Searchディレクトリはメール送信が必要ありませんが明日のチュートリアル以降必要となります。
それから、Zend autoloaderを登録する簡単な方法を用意するためProjectConfigurationクラスに下記コードを追加します。
<?php // config/ProjectConfiguration.class.php class ProjectConfiguration extends sfProjectConfiguration { static protected $zendLoaded = false; static public function registerZend() { if (self::$zendLoaded) { return; } set_include_path(sfConfig::get('sf_lib_dir').'/vendor'.PATH_SEPARATOR.get_include_path()); require_once sfConfig::get('sf_lib_dir').'/vendor/Zend/Loader/Autoloader.php'; Zend_Loader_Autoloader::getInstance(); self::$zendLoaded = true; } // ... }
メールの送信
管理者がアフィリエイトを有効にしたときメールを送信するためのactivateアクションを編集します。
<?php // apps/backend/modules/affiliate/actions/actions.class.php class affiliateActions extends autoAffiliateActions { public function executeListActivate() { $affiliate = $this->getRoute()->getObject(); $affiliate->activate(); // send an email to the affiliate ProjectConfiguration::registerZend(); $mail = new Zend_Mail(); $mail->setBodyText(<<<EOF Your Jobeet affiliate account has been activated. Your token is {$affiliate->getToken()}. The Jobeet Bot. EOF ); $mail->setFrom('jobeet@example.com', 'Jobeet Bot'); $mail->addTo($affiliate->getEmail()); $mail->setSubject('Jobeet affiliate token'); $mail->send(); $this->redirect('@jobeet_affiliate'); } // ... }
コードが動作するよう、実在のメールアドレスであるjobeet@example.comに変更します。
Zend Mailライブラリの完全なチュートリアルはZend Frameworkのサイトが利用できます。
また明日
symfonyのRESTアーキテクチャのおかげで、プロジェクトにWebサービスを実装するのは非常に簡単になっています。
本日読み出し専用のWebサービスのコードを書きましたが、読み書きできるWebサービスの実装をするためのsymfonyの知識を充分あります。
フロントエンド、バックエンドのアフィリエイトアカウント生成フォームの実装は本当に簡単でプロジェクトに新要素を追加するための処理としてよく知られています
2日目の必要要件を覚えているでしょうか?
「アフィリエイトは返される求人数の制限ができ、カテゴリを指定することでよりクエリの対象を絞り込めます」
この機能の実装は簡単なので今晩にでもやってみてください。明日はJobeetサイトに足りない最後の要素である検索エンジンを実装していきます。