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

Giorno 16: Web Service

1.2 / Doctrine

Prima di iniziare

Nel giorno sei abbiamo definito la rotta per vedere i lavori, chiamata job_show_user in apps/frontend/config/routing.yml, abbiamo fatto un piccolo errore nella definizione. La rotta dovrebbe essere la seguente.

job_show_user:
  url:     /job/:company_slug/:location_slug/:id/:position_slug
  class:   sfDoctrineRoute
  options:
    model: JobeetJob
    type:  object
    method_for_query: retrieveActiveJob
  param:   { module: job, action: show }
  requirements:
    id: \d+

Notare la modifica tra usare method e method_for_query. Era un piccolo bug in symfony ed un errore nel tutorial, quindi è necessario aggiornare il progetto.

Occorre anche una piccola modifica allo schema JobAffiliate, per includere una relazione molti-a-molti con JobeetCategory. Lo schema completo è nel giorno tre, le cose da aggiungere sono qui di seguito.

JobeetAffiliate:
  # ...
  relations:
    JobeetCategories:
      class: JobeetCategory
      refClass: JobeetCategoryAffiliate
      local: affiliate_id
      foreign: category_id
      foreignAlias: JobeetAffiliates

Assicuratevi di ricostruire il modello dopo aver fatto le modifiche:

$ php symfony doctrine:build-model

Con l'aggiunta dei feed su Jobeet, chi è in cerca di lavoro sarà informato sulle nuove offerte di lavoro in tempo reale. Dall'altra parte, quando si inserisce un lavoro, si vorrà avere la maggiore esposizione possibile. Se il proprio lavoro viene inviato tramite feed a molti piccoli siti, si avranno maggiori opportunità di trovare la persona giusta. Questo è il potere della coda lunga. Gli affiliati potranno pubblicare gli ultimi lavori inseriti nei loro siti, grazie ai web service che svilupperemo oggi.

Affiliati

Dai requisiti del giorno 2:

"Storia F7: un affiliato recupera l'attuale lista di inserzioni attive"

Le fixture

Creiamo un nuovo file di fixture per gli affiliati:

# data/fixtures/affiliates.yml
JobeetAffiliate:
  sensio_labs:
    url:       http://www.sensio-labs.com/
    email:     [email protected]
    is_active: true
    token:     sensio_labs
    JobeetCategories: [programming]
 
  symfony:
    url:       /
    email:     [email protected]
    is_active: false
    token:     symfony
    JobeetCategories: [design, programming]

Creare le righe per una tabella di collegamento di una relazione molti-a-molti è facile come definire un array con chiave il nome della relazione. Il contenuto dell'array è costituito dai nomi degli oggetti, come definiti nei file delle fixture. Si possono collegare oggetti di file differenti, ma i nomi devono essere definiti in anticipo.

Nel file delle fixture, i token sono fissi per semplificare i test, ma quando un vero utente richiede un account, il token dovrà essere generato:

// lib/model/doctrine/JobeetAffiliate.php
class JobeetAffiliate extends BaseJobeetAffiliate
{
  public function preValidate($event)
  {
    $object = $event->getInvoker();
 
    if (!$object->getToken())
    {
      $object->setToken(sha1($object->getEmail().rand(11111, 99999)));
    }
  }
 
  // ...
}

Ora si possono ricaricare i dati:

$ php symfony doctrine:data-load

Il web service dei lavori

Come sempre, quando si crea una nuova risorsa, è una buona abitudine definire prima l'URL:

# apps/frontend/config/routing.yml
api_jobs:
  url:     /api/:token/jobs.:sf_format
  class:   sfDoctrineRoute
  param:   { module: api, action: list }
  options: { model: JobeetJob, type: list, method: getForToken }
  requirements:
    sf_format: (?:xml|json|yaml)

Per questa rotta, la variabile speciale sf_format conclude l'URL, i suoi valori validi sono xml, json, o yaml.

Il metodo getForToken() sarà chiamato quando l'azione recupera l'insieme di oggetti legati alla rotta:

// lib/model/doctrine/JobeetJobTable.class.php
class JobeetJobTable extends Doctrine_Table
{
  public function getForToken(array $parameters)
  {
    $affiliate = Doctrine::getTable('JobeetAffiliate')->findOneByToken($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();
  }
 
  // ...
}

Se il token non esiste nel database, sarà sollevata un'eccezione sfError404Exception. Questa classe di eccezioni è quindi automaticamente convertita in una risposta 404. Questo è il modo più semplice per generare una pagina 404 da una classe del modello.

Il metodo getForToken() usa un nuovo metodo chiamato getActiveJobs() e restituisce la lista dei lavori attualmente attivi:

// lib/model/doctrine/JobeetAffiliate.class.php
class JobeetAffiliate extends BaseJobeetAffiliate
{
  public function getActiveJobs()
  {
    $q = Doctrine_Query::create()
      ->select('j.*')
      ->from('JobeetJob j')
      ->leftJoin('j.JobeetCategory c')
      ->leftJoin('c.JobeetAffiliates a')
      ->where('a.id = ?', $this->getId());
 
    $q = Doctrine::getTable('JobeetJob')->addActiveJobsQuery($q);
 
    return $q->execute();
  }
 
  // ...
}

L'ultimo passo è quello di creare l'azione api ed i template. Inizializziamo il modulo con il task generate:module:

$ php symfony generate:module frontend api

L'azione

Tutti i formati condivideranno la stessa azione list:

// 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());
  }
}

Al posto di passare un array di oggetti di tipo JobeetJon ai template, passiamo un array di stringhe. Dato che abbiamo tre diversi template per la stessa azione, la logica di processare i valori è stata spostata fuori nel metodo JobeetJob::asArray():

// lib/model/doctrine/JobeetJob.class.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(),
    );
  }

Il formato xml

Supportare il formato xml è semplice come creare un template:

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

Il formato json

Il supporto al formato JSON è simile:

<!-- 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; ?>
]

Il formato yaml

Per i formati built-in, symfony fornisce una configurazione nel background, come cambiare il content type, o disabilitare il layout.

Dato che il formato YAML non è nella lista del formati built-in di richiesta, il content type della risposta può venir cambiato ed il layout disabilitato nell'azione:

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;
    }
  }
}

Nell'azione, il metodo setLayout() cambia il layout di default o lo disabilita quando viene impostato a false.

Il template per YAML è il seguente:

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

Se cercate di chiamare il web service con un token non valido, riceverete una pagina 404 in XML per il formato XML e una pagina 404 JSON per il formato JSON. Ma per il formato YAML, symfony non sa cosa visualizzare.

Quando create un formato, una pagina d'errore personalizzata dev'essere creata. Il template sarà usato per le pagine 404 e tutte le altre eccezioni.

Dato che le eccezioni dovrebbero essere differenti negli ambienti di produzione e di sviluppo, due file sono necessari (config/error/exception.yaml.php per il debug e config/error/error.yaml.php per la produzione):

// 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,
))) ?>

Prima di provare, occorre creare un layout per il formato YAML:

// apps/frontend/templates/layout.yaml.php
<?php echo $sf_content ?>

404

tip

Sovrascrivere i template dell'errore 404 e delle eccezioni è semplice come creare un file nella cartella config/error/.

Test per i Web Service

Per testare il web service, copiate le fixture degli affiliati da data/fixtures/ a text/fixtures/ e rimpiazzate il contenuto del file auto-generato apiActionsTest.php con il seguente:

// 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', 33)->
 
  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()
;

In questo test, noterete due nuovi metodi:

  • isFormat(): Testa il formato della richiesta
  • contains(): Per i formati non-HTML, controlla se la risposta contiene l'estratto dal testo aspettato

Il form di richiesta di affiliazione

Ora che il web service è pronto da usare, creiamo il form per creare gli account degli affiliati. Descriveremo ancora il classico processo di aggiunta di una nuova feature all'applicazione.

Rotte

Avete indovinato. La rotta è la prima cosa che creiamo:

# apps/frontend/config/routing.yml
affiliate:
  class:   sfDoctrineRouteCollection
  options:
    model: JobeetAffiliate
    actions: [new, create]
    object_actions: { wait: GET }

È un classico insieme di rotte Doctrine con una nuova opzione di configurazione: actions. Poiché non abbiamo bisogno di tutte e sette le azioni definite dalla rotta, l'opzione actions dice alla rotta di far corrispondere solo le azioni new e create. La rotta aggiuntiva wait sarà usata per dare al novello affiliato un po' di feedback sul suo account.

Inizio

Il classico secondo passo è generare un modulo:

$ php symfony doctrine:generate-module frontend affiliate JobeetAffiliate --non-verbose-templates

Template

Il task doctrine:generate-module genera le classiche sette azioni ed i loro template corrispondenti. Nella cartella templates/, cancelliamo tutti i file tranne _form.php e newSuccess.php. E per i file che manteniamo, sostituiamo il contenuto con il seguente:

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

Creiamo il template waitSuccess.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>

Infine, cambiamo il link nel footer per puntare al modulo affiliate:

// apps/frontend/templates/layout.php
<li class="last"><a href="<?php echo url_for('@affiliate_new') ?>">Become an affiliate</a></li>

Azioni

Di nuovo, siccome useremo solo il form di creazione, apriamo il file actions.class.php e rimuoviamo tutti i metodi tranne executeNew(), executeCreate() e processForm().

Per l'azione processForm(), cambiamo l'URL di rinvio all'azione wait:

// apps/frontend/modules/affiliate/actions/actions.class.php
$this->redirect($this->generateUrl('affiliate_wait', $jobeet_affiliate));

L'azione wait è semplice, perché non vogliamo passare nulla al template:

// apps/frontend/modules/affiliate/actions/actions.class.php
public function executeWait()
{
}

L'affiliato non può scegliere il suo token, né può attivare il suo account. Apriamo il file JobeetAffiliateForm per personalizzare il form:

// lib/form/doctrine/JobeetAffiliateForm.class.php
class JobeetAffiliateForm extends BaseJobeetAffiliateForm
{
  public function configure()
  {
    unset($this['is_active'], $this['token'], $this['created_at'], $this['updated_at']);
 
    $this->widgetSchema['jobeet_categories_list']->setOption('expanded', true);
    $this->widgetSchema['jobeet_categories_list']->setLabel('Categories');
 
    $this->validatorSchema['jobeet_categories_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));
  }
}

Il framework dei form supporta le relazioni molti-a-molti, come ogni altra colonna. Per default, una relazione del genere è visualizzata come un menù a tendina, grazie al widget sfWidgetFormChoice. Come abbiamo visto nel giorno 10, abbiamo modificato la visualizzazione usando l'opzione expanded. Le email e gli URL tendono ad essere un po' più lunghi della dimensione predefinita di un tag input, ma gli attributi HTML possono essere impostati usando il metodo setAttribute().

Form affiliati

Test

L'ultimo passo è scrivere alcuni test funzionali per la nuova feature:

// 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'                          => '[email protected]',
    'jobeet_categories_list'         => array(Doctrine::getTable('JobeetCategory')->findOneBySlug('programming')->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' => '[email protected]',
  )))->
  with('form')->isError('jobeet_categories_list')
;

La gestione degli affiliati

Per il backend, va creato un modulo affiliate per consentire agli amministratori di attivare gli affiliati:

$ php symfony doctrine:generate-admin backend JobeetAffiliate --module=affiliate

Per accedere al modulo appena creato, aggiungiamo un link nel menù principale col numero di affiliati che devono essere attivati:

<!-- apps/backend/templates/layout.php -->
<li>
  <a href="<?php echo url_for('@jobeet_affiliate_affiliate') ?>">
    Affiliates - <strong><?php echo Doctrine::getTable('JobeetAffiliate')->countToBeActivated() ?></strong>
  </a>
</li>
 
// lib/model/doctrine/JobeetAffiliateTable.class.php
class JobeetAffiliateTable extends Doctrine_Table
{
  public function countToBeActivated()
  {
    $q = $this->createQuery('a')
      ->where('a.is_active = ?', 0);
 
    return $q->count();
  }

Poiché l'unica azione necessaria nel backend è l'attivazione o la disattivazione degli account, cambiamo la sezione config del generatore, per semplificare un po' l'interfaccia, ed aggiungiamo un link per attivare gli account direttamente dalla lista:

# 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]

Per rendere gli amministratori più produttivi, cambiamo i filtri di default per mostrare solo gli affiliati che devono essere attivati:

// apps/backend/modules/affiliate/lib/affiliateGeneratorConfiguration.class.php
class affiliateGeneratorConfiguration extends BaseAffiliateGeneratorConfiguration
{
  public function getFilterDefaults()
  {
    return array('is_active' => '0');
  }
}

Il solo codice da scrivere è per le azioni activate e deactivate:

// apps/backend/modules/affiliate/actions/actions.class.php
class affiliateActions extends autoAffiliateActions
{
  public function executeListActivate()
  {
    $this->getRoute()->getObject()->activate();
 
    $this->redirect('@jobeet_affiliate_affiliate');
  }
 
  public function executeListDeactivate()
  {
    $this->getRoute()->getObject()->deactivate();
 
    $this->redirect('@jobeet_affiliate_affiliate');
  }
 
  public function executeBatchActivate(sfWebRequest $request)
  {
    $q = Doctrine_Query::create()
      ->from('JobeetAffiliate a')
      ->whereIn('a.id', $request->getParameter('ids'));
 
    $affiliates = $q->execute();
 
    foreach ($affiliates as $affiliate)
    {
      $affiliate->activate();
    }
 
    $this->redirect('@jobeet_affiliate_affiliate');
  }
 
  public function executeBatchDeactivate(sfWebRequest $request)
  {
    $q = Doctrine_Query::create()
      ->from('JobeetAffiliate a')
      ->whereIn('a.id', $request->getParameter('ids'));
 
    $affiliates = $q->execute();
 
    foreach ($affiliates as $affiliate)
    {
      $affiliate->deactivate();
    }
 
    $this->redirect('@jobeet_affiliate_affiliate');
  }
}
 
// lib/model/doctrine/JobeetAffiliate.class.php
class JobeetAffiliate extends BaseJobeetAffiliate
{
  public function activate()
  {
    $this->setIsActive(true);
 
    return $this->save();
  }
 
  public function deactivate()
  {
    $this->setIsActive(false);
 
    return $this->save();
  }
 
  // ...
}

Backend affiliati

Inviare email

Ogni volta che l'account di un affiliato viene attivato da un amministratore, un'email deve essere inviata per confermare la sottoscrizione e fornire il token.

PHP ha diverse buone librerie per l'invio di email, come SwiftMailer, Zend_Mail, e ezcMail. Poiché useremo alcune altre librerie di Zend Framework nel futuro, scegliamo Zend_Mail per inviare le nostre email.

Installare e configurare Zend Framework

La libreria Zend Mail è parte di Zend Framework. Siccome non vogliamo l'intero Zend Framework, installeremo solo le parti necessarie nella cartella lib/vendor/, insieme a symfony stesso.

Innanzitutto, scarichiamo Zend Framework e scompattiamo i file nella cartella lib/vendor/Zend/.

note

Le istruzioni seguenti sono state testate con la versione 1.8.0 di

Si può ripulire la cartella cancellando tutto tranne i seguenti file e cartelle:

  • Exception.php
  • Loader/
  • Loader.php
  • Mail/
  • Mail.php
  • Mime/
  • Mime.php
  • Search/

note

La cartella Search/ non serve per l'invio di email, ma sarà usata per il tutorial di domani.

Quindi, aggiungiamo il seguente codice alla classe ProjectConfiguration, per fornire un modo semplice per registrare l'autoloader di Zend:

// 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.php';
    Zend_Loader_Autoloader::getInstance();
    self::$zendLoaded = true;
  }
 
  // ...
}

Inviare le email

Modifichiamo l'azione activate:

// apps/backend/modules/affiliate/actions/actions.class.php
class affiliateActions extends autoAffiliateActions
{
  public function executeListActivate()
  {
    $affiliate = $this->getRoute()->getObject();
    $affiliate->activate();
 
    // invia un'email all'affiliato
    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('[email protected]', 'Jobeet Bot');
    $mail->addTo($affiliate->getEmail());
    $mail->setSubject('Jobeet affiliate token');
    $mail->send();
 
    $this->redirect('@jobeet_affiliate_affiliate');
  }
 
  // ...
}

Per far funzionare il codice, occorre cambiare [email protected] in un indirizzo email esistente.

note

Un tutorial completo sulla libreria Zend_Mail è disponibile sul sito di Zend Framework.

A domani

Grazie all'architettura REST di symfony, è molto semplice implementare dei web service per i propri progetti. Sebbene oggi abbiamo scritto del codice per un web service in sola lettura, avete abbastanza conoscenze su symfony da poter implementare un web service in lettura-scrittura.

L'implementazione del form di creazione degli account per gli affiliati nel frontend e della sua controparte in backend è stata molto facile, perché ora avete familiarità col processo di aggiunta di nuove feature al vostro progetto.

Se ricordate i requisiti dal giorno 2:

"L'affiliato può inoltre limitare il numero di lavori da restituire e raffinare la propria richiesta specificando una categoria."

L'implementazione di questa feature è così facile che ve la lasceremo fare stasera.

Domani implementeremo l'ultima feature mancante del sito Jobeet, il motore di ricerca.