Testando comandos do Symfony que usam serviços da aplicação

Se você tem um comando no Symfony2 que usa serviços da aplicação, como um ORM por exemplo, e seguir o modelo que a documentação do framework fornece para escrever testes unitários, poderá ver erros com os a seguir:

Fatal error: Call to undefined method Symfony\Component\Console\Application::getKernel() in [...]/vendor/symfony/symfony/src/Symfony/Bundle/FrameworkBundle/Command/ContainerAwareCommand.php on line [...]

ao acessar o kernel da aplicação, a partir do comando, ou:

Fatal error: Call to a member function getParameter() on a non-object in [...]

ao tentar acessar algum método do container.

O primeiro caso, acontece quando você usa a classe Symfony\Component\Console\Application ao invés de Symfony\Bundle\FrameworkBundle\Console\Application. Verifique os uses da sua classe de testes e ajuste se for necessário.

A segunda exceção é lançada quando você está usando a classe Application correta, mas usando um kernel inválido na sua instanciação. Talvez exista outro modo de fazer isso, mas para mim funcionou extender a classe Symfony\Bundle\FrameworkBundle\Test\WebTestCase no lugar de \PHPUnit_Framework_TestCase e construir a aplicação da seguinte forma:

$application = new Application(self::createClient()->getKernel());

Forms no Symfony2 : Validando apenas os campos submetidos

No último post, sobre CRUDs RESTful com Symfony2, uma das características do código de exemplo era usar o método PUT na atualização do objeto. Seguindo isso, estive fazendo algumas exeperiências com o mesmo form do Symfony na criação e atualização. No caso do update, gostaria de enviar apenas os campos a serem alterados (em um objeto que tenha ‘id‘, ‘title‘, ‘description‘ e ‘date‘ como atributos, por exemplo), assim:

$ curl -X PUT -d "title=Testando a atualizacao" http://localhost:8000/api/resources/123

Então descobri que o método que substitui o bind a partir da versão 2.3 do Symfony, tem um segundo parâmetro para indicar se os campos não enviados devem ou não ser limpos, se não forem, terão o valor do objeto original mantido:

$clearMissing = 'PUT' !== $request->getMethod();
$form->submit($request, $clearMissing);

Simples assim!

Escrevendo um CRUD RESTful com Symfony2

Antes de começar, a intenção desse post não é incentivar ninguém a reinventar a roda, mas mostrar como as características de roteamento do Symfony podem ser usadas para manipular métodos HTTP. Eu, particularmente, não cheguei a experimentar, mas existe um bundle bastante mencionado que promete facilitar a vida de quem quer desenvolver webservices REST em cima do Symfony2: o FOSRestBundle. Esclarecido isso, vamos lá!

Para esse CRUD, vou usar a seguinte convenção:

  • GET /api/resources/ — buscar todos os objetos do nosso recurso;
  • POST /api/resources/ — criar um novo objeto;
  • GET /api/resources/123/ — buscar um objeto específico, de acordo com o código informado;
  • PUT /api/resources/123/ — atualizar um objeto;
  • DELETE /api/resources/123/ — excluir um objeto existente.

Definido isso, podemos escrever controller abaixo. Ah! Estou omitindo vários detalhes de lógica relacionados à busca e persistência das informações na camada de modelo (M do MVC) para facilitar o entendimento.

<?php

namespace Acme\DemoBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

/**
 *
 * @Route("/api/resources")
 */
class ApiController extends Controller
{

    /**
     *
     * @param \Symfony\Component\HttpFoundation\Request $request
     * @return array|\Symfony\Component\HttpFoundation\Response
     *
     * @Route("/", name="resources_api_index")
     * @Method("GET")
     */
    public function indexAction(Request $request)
    {
        // ...

        return $this->createJsonResponse(array(
            'resources' => $resources,
        ));
    }

    /**
     *
     * @param \Symfony\Component\HttpFoundation\Request $request
     * @return array|\Symfony\Component\HttpFoundation\Response
     *
     * @Route("/", name="resources_api_create")
     * @Method("POST")
     */
    public function createAction(Request $request)
    {

        // ...

        if (!$success) { // Algum erro de validação, de comunicação com a camada de persistência etc.
            return $this->createJsonResponse(array(
                'error' => 'Não foi possível criar o objeto. Detalhes: ...',
            ), 400);
        }

        return $this->createJsonResponse(array(
            'resource' => $resource,
        ), 201);
    }

    /**
     *
     * @param int $id
     * @return array|\Symfony\Component\HttpFoundation\Response
     *
     * @Route("/{id}", name="resources_api_retrieve")
     * @Method("GET")
     */
    public function retrieveAction($id = 0)
    {
        $id = (int) $id;

        // ...

        if (null === $resource) {
            return $this->createJsonResponse(array(
                'error' => 'Recurso não encontrado. Detalhes: ...',
            ), 404);
        }

        return $this->createJsonResponse(array(
            'resource' => $resource,
        ));
    }

    /**
     *
     * @param int $id
     * @return array|\Symfony\Component\HttpFoundation\Response
     *
     * @Route("/{id}", name="resource_api_update")
     * @Method("PUT")
     */
    public function updateAction($id = 0)
    {
        $id = (int) $id;

        // ...

        if (null === $resource) {
            return $this->createJsonResponse(array(
                'error' => 'Recurso não encontrado. Detalhes: ...',
            ), 404);
        }

        // ...

        if (!$success) { // Algum erro de validação, de comunicação com a camada de persistência etc.
            return $this->createJsonResponse(array(
                'error' => 'Não foi possível criar o objeto. Detalhes: ...',
            ), 400);
        }

        return $this->createJsonResponse(array(
            'resource' => $resource,
        ));
    }

    /**
     *
     * @param int $id
     * @return array|\Symfony\Component\HttpFoundation\Response
     *
     * @Route("/{id}", defaults={"id"=null}, name="resources_api_delete")
     * @Method("DELETE")
     */
    public function deleteAction($id = 0)
    {
        $id = (int) $id;

        // ...

        if (null === $resource) {
            return $this->createJsonResponse(array(
                'error' => 'Recurso não encontrado. Detalhes: ...',
            ), 404);
        }

        // ...

        return $this->createJsonResponse(array(
            'success' => 1,
        ));
    }

    /**
     *
     * @param array $data
     * @param int $code
     * @param array $headers
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function createJsonResponse(array $data, $code = 200, array $headers = array())
    {
        $response = new Response(json_encode($data), $code, $headers);
        $response->headers->set('Content-type', 'application/json');

        return $response;
    }

}

Os pontos que merecem antenção especial no nosso controller de exemplo são os seguintes:

@Route e @Method

As rotas se repetem entre as diferentes ações (/api/resources/ e /api/resources/{id}), o que faz com que o Symfony direcione para a ação correta é o método HTTP (GET, POST, PUT ou DELETE).

Códigos HTTP

No retorno, além do código padrão para sucesso — 200, amplamente usado — retornamos:

  • 201, para quando um novo objeto foi criado;
  • 400, para os casos em que há algum erro “capturável” no processamento do request; e
  • 404, para quando um objeto não foi encontrado.

Referências

Script de deploy para projetos que usam git

Há alguns meses atrás desenvolvi um script de deploy para um projeto em que estou trabalhando. Com o tempo vi a possibilidade de melhorar alguns pontos, especialmente com o uso do componente Console do Symfony2,  e agora disponibilizo uma versão pública que pode ser usada com projetos armazenados em um repositório git.

https://github.com/straube/deploy

Uma das limitações dessa versão é que ela trabalha apenas com SSH, mas em breve devo adicionar suporte a FTP.

Falha inexplicável (ou não) no composer install

Quando você vir falhas inexplicáveis (gerando exceções) ao executar o composer install, verifique se a opção apc.enable_cli, do PHP, não está ativada.

Usando o APC para cachear variáveis no PHP

Sempre associei o APC (Alternative PHP Cache) ao cache de opcode no PHP, e somente a isso. Acredito que essa ligação seja natural para muitas pessoas, mas o APC pode ir além disso, cacheando qualquer variável dentro do PHP. Para isso existem algumas funções na extensão: apc_add, apc_fetch, apc_delete etc.

Para um experimento básico é possível usar apenas as funções apc_add e apc_fetch, seguindo o padrão de design lazy loading:

class City
{
    private $state;
    public function getState()
    {
        $cacheKey = 'state';
        if (false === ($state = apc_fetch($cacheKey))) {
            $state = ... // Buscar o estado de algum lugar, talvez do BD.
            apc_add($cacheKey, $state, 3600);
        }
        return $state;
    }
}

A primeira vez que a função getState é chamada, ela carrega o estado e armazena no cache, nas próximas chamdas a variável armazenada no cache será usada, evitando o acesso ao banco de dados. Passada uma hora (3.600 segundos) a variável é removida automaticamente do cache e na chamada seguinte ao método, o banco será consultado novamente. O tempo de vida (TTL) do cache pode ser alterado conforme as características da aplicação e do contéudo a ser cacheado.

Sobre as chaves

A chave usada para armazenar a variável no APC é global e compartilhada entre todas as aplicações em um mesmo ambiente. Isso pode ser interessante, porque o cache é compartilhado e, além da questão de otimização, pode ser um caminho para soluções que envolvem a troca de informações entre requisições ou programas distintos, mas também pode gerar bugs inesperados e difíceis de identificar se diferentes scripts PHP usarem a mesma chave para diferentes propósitos. Assim é sempre muito importante criar chaves com nomes distintos e exclusivos.

Correção de bug no AvalancheImagineBundle

O AvalancheImagineBundle é um bundle super útil para o Symfony2. Ele adiciona funções de geração de thumbnails com caching ao framework. Porém, dependendo da estrutura de diretório e do número de requisições da aplicação, dois problemas podem acontecer:

  1. loop infinito de redirects; e
  2. bug na criação recursiva de diretórios devido a concorrência.

Para resolver essas situações em um projeto de Classificados da Gazeta do Povo, precisamos alterar o core do bundle e, ontem, nosso pull request foi aceito: https://github.com/avalanche123/AvalancheImagineBundle/pull/160.

Se você usa o AvalancheImagineBundle, atualize para última versão e evite esses erros.

Usando os comandos do Symfony2 isolados

Um componente muito útil do Symfony2 é o Console. Ele permite a criação de comandos que podem ser executados via terminal. Isso é muito útil para processos relacionados à manutenção do sistema e àqueles que devem estar na crontab, por exemplo. Assim como os demais componentes do framework, o Console pode ser usado isolado e isso é muito interessante para quando é necessário fazer uma aplicação que deve ser executada via linha de comando.

Para facilitar a criação de um aplicação baseada nesse componente, criei um projeto base no GitHub: https://github.com/straube/base-command. No README existem algumas instruções de como executar e customizar o código, mas ele basicamente adiciona, além do próprio Console, o registro automático de comandos dentro de um namespace específico através do componente Finder, também do Symfony2.

Simple Gallery — Uma simples galeria baseada em jQuery

Na maioria dos projetos que desenvolvemos na CodeKings, acabamos não conseguindo usar as opções de galerias disponíveis na web. Isso acontece porque a estrutura do plugin não é compatível com o layout ou faz muito mais coisas do que realmente precisamos. Nesses casos, acabamos escrevendo um script simples que resolve mais rapidamente do que se tivermos que adaptar alguma solução pronta. Com o tempo, percebi que esse script não mudava muito de um projeto para outro, então resolvi escrever uma versão genérica que é bem simples de customizar.

Publiquei o projeto no GitHub: https://github.com/straube/simple-gallery.

Gerando URLs absolutas para rotas do Symfony2

Nas classes que extendem, em algum nível, a classe Controller padrão do Symfony2 está disponível um método utilitário que é simplesmente um atalho para o gerador de rotas do serviço de roteamento (router). Esse método é o generateUrl($route, $parameters, $referenceType).

Na maioria dos casos de uso, como em redirects, passamos apenas um ou dois parâmetros para esse método, que são, respectivamente, o nome da rota e, opcionalmente, os parâmetros que devem ser aplicados a ela. O terceiro parâmetro, que acaba esquecido, traz algumas possibilidades que podem ser muito úteis. Um exemplo prático: precisei incluir na resposta de um webservice em JSON a URL para uma página da aplicação relacionada a objetos específicos, como esse serviço seria consumido por uma aplicativo mobile, precisava retornar a URL absoluta — incluindo o protocolo e domínio — e não apenas o caminho, que é o comportamento padrão do método. Para fazer isso, bastou passar a constante abaixo no terceiro parâmetro do método:

\Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL

Para dar mais legibilidade ao código, adicionei um use para a interface:

use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

// ...

class MainController extends Controller
{
  public function indexAction()
  {
    // ...
    $item['Url'] = $this->generateUrl('item_detail', array('id' => $item['Id']), UrlGeneratorInterface::ABSOLUTE_URL);
  }
}