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

Giorno 15: Web Service

1.4 / Doctrine
Symfony version
1.2
Language ORM

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.class.php
class JobeetAffiliate extends BaseJobeetAffiliate
{
  public function save(Doctrine_Connection $conn = null)
  {
    if (!$this->getToken())
    {
      $this->setToken(sha1($this->getEmail().rand(11111, 99999)));
    }
 
    return parent::save($conn);
  }
 
  // ...
}

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_Core::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_Core::getTable('JobeetJob')->addActiveJobsQuery($q);
 
    return $q->execute();
  }
 
  // ...
}

L'ultimo passo è quello di creare l'azione api e 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 e 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')->begin()->
    isValid()->
    checkElement('job', 32)->
  end()->
 
  info('3 - The web service supports the JSON format')->
  get('/api/sensio_labs/jobs.json')->
  with('request')->isFormat('json')->
  with('response')->matches('/"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')->
    matches('/category\: Programming/')->
  end()
;

In questo test, noterete tre nuovi metodi:

  • isValid(): Checks whether or not the XML response is well formed
  • isFormat(): Testa il formato della richiesta
  • matches(): 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 e 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(sfWebRequest $request)
{
}

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()
  {
    $this->useFields(array(
      'url', 
      'email', 
      'jobeet_categories_list'
    ));
    $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));
  }
}

The new sfForm::useFields() method allows to specify the white list of fields to keep. All non mentionned fields will be removed from the form.

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 a 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_Core::getTable('JobeetCategory')->findOneBySlug('programming')->getId()),
  )))->
  with('response')->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') ?>">
    Affiliates - <strong><?php echo Doctrine_Core::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, e 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');
  }
 
  public function executeListDeactivate()
  {
    $this->getRoute()->getObject()->deactivate();
 
    $this->redirect('jobeet_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');
  }
 
  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');
  }
}
 
// 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

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.

Ogni volta che l'account di un affiliato è attivato dall'amministratore, una email deve essere inviata agli affiliati per confermare la loro iscrizione e dargli un token. L'invio delle email è l'argomento di cui parleremo domani.