• Inicio
  • Web
  • Contacto
El Blog del desarrollador
Menu
  • Inicio
  • Web
  • Contacto
Inicio › Slim Framework
  • Usando Doctrine 2 con Slim Framework y path personalizado en Composer

    malakana     22 noviembre, 2015     Sin comentario     Doctrine 2, General, Slim Framework

    Slim Framework es una herramienta ideal para construir proyectos no excesivamente grandes de manera rápida, ordenada y aprovechando según tus necesidades las nuevas tendencias en programación que se están usando hoy en día, por lo que usando Slim vas a poder configurar tu proyecto para usar componentes de otros framework a tu gusto, pudiendo usar namespaces, autocargado de librerías y demás funcionalidades que hoy en día están muy de moda.

    Para la gestión de las dependencias vamos a usar composer.Instalaremos Slim Framework y Doctrine 2 para para trabajar con BD. Además de instalar lo dicho, vamos a preparar un path en nuestro proyecto para el autocargado de nuestras propias clases siguiendo el estandar PSR-4.
    Pra empezar como es lógico debemos de disponer de composer instalado en el sistema. Comenzamos:

    Creamos nuestro directorio para el proyecto:

    mkdir proyecto
    chmod 777 proyecto
    cd proyecto
    

    Una vez dentro del directorio de nuestro proyecto creamos nuestro fichero composer.json con el siguiente contenido, que nos descargará la última versión de Slim, el ORM Doctrine 2 y nos prepara un path que hace referencia a la carpeta src/ de nuestro proyecto cuyo namespace será Custom siguiendo el estándar psr-4 que es el que recomienda composer en estos momentos.

    {
        "require": {
            "slim/slim": "^2.6",
            "doctrine/orm": "*"
        },
        "autoload": {
            "psr-4": {"Custom\\": "src/"}
        }
    }
    

    Ejecutamos en la consola con el comando “composer install” y si no ha habido problemas y todo se ha instalado correctamente, nuestras dependencias estarán disponibles ya en el directorio vendor de nuestro proyecto. Además crearemos la carpeta src en el raíz para nuestras propias clases.

    Lo siguiente es configurar Doctrine para que podamos usar la consola que nos facilita el uso de un montón de tareas a la hora de trabajar con BD como autogeneración de entidades, validación de las mismas, etc… Más info sobre el uso de la consola aquí: Documentación consola Doctrine.

    Para ponerse manos a la obra en nuestro proyecto vamos a crear un fichero llamado bootstrap.php que usaremos de cargador de librerías y configuración. En el usaremos el cargador de composer, instanciaremos Slim y pondremos algo de configuración necesaria:

    <?php
    
    require 'vendor/autoload.php';
    
    $app = new \Slim\Slim();
    
    //Doctrine
    use Doctrine\ORM\Tools\Setup;
    use Doctrine\ORM\EntityManager;
    
    $paths = array(
        __DIR__ . "/src"
    );
    
    $isDevMode = false;
    // the connection configuration
    $dbParams = array(
        'driver' => 'pdo_mysql',
        'host' => '127.0.0.1',
        'user' => 'root',
        'password' => '',
        'dbname' => 'doctrine',
    );
    
    $configDoctrine = Setup::createAnnotationMetadataConfiguration($paths, $isDevMode, null, null, false);
    
    $entityManager = EntityManager::create($dbParams, $configDoctrine);
    
    

    Para habilitar la consola debemos crear un fichero en el raíz de nuestro proyecto llamado “cli-config.php” donde usaremos las librerías Doctrine para la consola y el bootstrap de configuración del proyecto en el cual hemos definido los parametros de conexión a nuesta base de datos además de alguna cosa más y hemos creado una instancia del entityManager para el trabajo con Doctrine.

    <?php
    
    use Doctrine\ORM\Tools\Console\ConsoleRunner;
    
    require_once 'bootstrap.php';
    
    return ConsoleRunner::createHelperSet($entityManager);
    
    

    Ahora ya nos podemos ir a la consola del sistema y en el directorio del proyecto usar la consola Doctrine para ejecutar tareas:

    php vendor/bin/doctrine
    

    Si al ejecutar el siguiente comando obtenemos una respuesta como esta es que la consola ya está disponible:

    Doctrine Command Line Interface version 2.5.1
    
    Usage:
      command [options] [arguments]
    
    Options:
      -h, --help            Display this help message
      -q, --quiet           Do not output any message
      -V, --version         Display this application version
          --ansi            Force ANSI output
          --no-ansi         Disable ANSI output
      -n, --no-interaction  Do not ask any interactive question
      -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
    
    Available commands:
      help                            Displays help for a command
      list                            Lists commands
     dbal
      dbal:import                     Import SQL file(s) directly to Database.
      dbal:run-sql                    Executes arbitrary SQL directly from the 
      ....
    

    Vamos a crear una entidad en nuestro path que va a representar una tabla de ejemplo para trabajar en nuestro proyecto en la carpeta src/entity/Test.php con el siguiente código:

    <?php
    
    //Una vez creada la entidad ejecutamos la consola de Doctrine para que cree esta tabla en la base de datos
    //php vendor/bin/doctrine orm:schema-tool:create
    
    namespace Custom\Entity;
    
    use Doctrine\ORM\Mapping as ORM;
    
    /**
     * Test
     *
     * @ORM\Table(name="test")
     * @ORM\Entity
     */
    class Test {
        /**
         * @ORM\Column(name="id", type="integer")
         * @ORM\Id
         * @ORM\GeneratedValue(strategy="AUTO")
         */
        private $id;
    
    
        /**
         * @ORM\Column(name="name", type="string", length=255)
         */
        private $name;
    
    
        /**
         * @ORM\Column(name="age", type="integer", length=3)
         */
        private $age;
    
    
        public function __get($property) {
    
            if (property_exists($this, $property)) {
                return $this->$property;
            }
    
        }
    
        public function __set($property, $value) {
    
            if (property_exists($this, $property)) {
    
                $this->$property = $value;
    
            }
        }
    }
    
    

    Ejecutamos la consola de Doctrine y nos creará automáticamente la tabla en la BD configurada en bootstrap:

    php vendor/bin/doctrine orm:schema-tool:create
    

    Podemos insertar unos cuanto registros en nuestra tabla para pruebas:

    INSERT INTO `test` (`id`, `name`, `age`) VALUES
        (1, 'John Snow', 33),
        (2, 'Albert Jackson', 45),
        (3, 'Sonia Smith', 39),
        (4, 'Mary Johnson', 49),
        (5, 'Tom Hardy', 25);
    

    Ahora creamos un index.php en nuestro raíz y creamos una ruta con Slim donde usaremos el entityManager para cargar los registros y renderizaremos un template para la vista de nuestra ruta:

    <?php
    
    require 'bootstrap.php';
    
    //Ruta para generar un csv desde una tabla de base de datos
    $app->get('/home(/)', function () use ($app,$entityManager) {
    
        $result = $entityManager->getRepository('\Custom\Entity\Test')->findAll();
    
        $app->render('home.phtml', array(
            'result' => $result
        ));
    
    });
    
    $app->run();
    

    Creamos la carpeta templates en el raíz y la vista dentro de esta con el siguiente contenido:

    <!DOCTYPE html>
    <html>
    <head>
        <title>Slim Framework + Doctrine + Custom Path en Composer</title>
    </head>
    <body>
    <h1>Usando Doctrine 2 en Slim Framework</h1>
    
    <?php
    
    echo '<table>';
    
    foreach ($result as $row) {
    
        echo '<tr>
                <td>'.$row->__get('name').'</td>
                <td>'.$row->__get('age').'</td>
              </tr>';
    }
    
    echo '</table>';
    
    ?>
    
    </body>
    </html>
    
    

    Y listo, tenemos un proyecto Slim con Doctrine 2 y su consola disponible además de un path para cargar nuestras clases personalizadas. Fácil, rápido y ligero.
    Aquí podéis descargar el proyecto de prueba del tutorial desde mi cuenta de git.

  • Usando Oauth 2 junto con Slim Framework para crear API segura: Parte2

    malakana     1 octubre, 2015     Sin comentario     General, Oauth 2, Slim Framework

    En el post anterior explicamos como configurar un servidor Oauth 2 para php que nos genere tokens de acceso validos con la intención de securizar nuestra API. En esta segunda parte del post vamos a desarrollar nuestra api apoyándonos en un microframework como Slim Framework y realizando conexiones CURL a nuestro servidor Oauth 2 para que nos valide si los token de accesos que enviamos están autorizados a acceder a la información de la API. Usaremos scopes para controlar los permisos de acceso a todos los métodos de nuestra API, vamos a ello.

    /**
     * functions.php - Funciones para la conexión CURL que comproborá el token de acceso a la API
     * entities.php - Entidades que usaremos al extraer los datos desde la Base de datos
     */
    require 'functions.php';
    require 'entities.php';
    /**
     * Autoloading SlimFramewok
     */
    require 'vendor/autoload.php';
    //
    
    /**
     * $pdo - Adaptador para la base de datos usada para almacenar los valores oauth en la BD
     * $app - Instanciación de Slim
     */
    
    $pdo = new PDO('mysql:host=localhost;dbname=bdname', 'user', 'password', array(
        PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'utf8'",
    ));
    $app = new \Slim\Slim();
    

    Para conectar a todos los métodos de nuestra api vamos a usar envío POST. A continuación el listado de métodos:

     * Listado de usuarios
     * url ejemplo: http://miaplicacion/usuarios/
     */
    $app->post('/usuarios(/)', function () use ($app, $pdo) {
    
        if ($app->request()->post('access_token')) {
    
            if (curlCheckToken($app->request()->post('access_token') ,'usuarios')) {
    
                $stmt = $pdo->prepare("SELECT * FROM usuarios");
                $stmt->execute();
                $resultado = $stmt->fetchAll(PDO::FETCH_CLASS, "Usuarios");
    
                if ($resultado) {
                    echo json_encode($resultado);
                }
                else {
                    echo json_encode(array(
                        'error' => 'No hay registros'
                    ));
                }
            }
        }
    });
    
    /**
     * Información de un usuario concreto, $id = id del usuario
     * Url ejemplo: http://miaplicacion/usuarios/1/
     */
    $app->post('/usuarios/:id(/)', function ($id) use ($app, $pdo) {
    
        if ($app->request()->post('access_token')) {
    
            if (curlCheckToken($app->request()->post('access_token') ,'usuario')) {
    
                $stmt = $pdo->prepare("SELECT * FROM usuarios WHERE id = :id");
                $stmt->bindParam(':id', $id, PDO::PARAM_INT);
                $stmt->execute();
                $resultado = $stmt->fetchAll(PDO::FETCH_CLASS, "Usuario");
    
                if ($resultado) {
                    echo json_encode($resultado);
                }
                else {
                    echo json_encode(array(
                        'error' => 'Usuario no encontrado'
                    ));
                }
            }
        }
    });
    
    /**
     * Listado de mensajes de un usuario, $id = id del usuario
     * Url ejemplo: http://miaplicacion/usuarios/1/mensajes/
     */
    $app->post('/usuarios/:id/mensajes(/)', function ($id) use ($app, $pdo) {
    
        if ($app->request()->post('access_token')) {
    
            if (curlCheckToken($app->request()->post('access_token') ,'mensajes')) {
    
                $stmt = $pdo->prepare("SELECT * FROM mensajes WHERE usuario_id = :id");
                $stmt->bindParam(':id', $id, PDO::PARAM_INT);
                $stmt->execute();
                $resultado = $stmt->fetchAll(PDO::FETCH_CLASS, "Mensajes");
    
                if ($resultado) {
                    echo json_encode($resultado);
                }
                else {
                    echo json_encode(array(
                        'error' => 'No hay mensajes de este usuario'
                    ));
                }
            }
        }
    });
    
    /**
     * Un mensaje de un usuario en concreto via $id = id del usuario y $mensajes = id del mensaje
     * Url ejemplo: http://miaplicacion/usuarios/1/mensajes/3/
     */
    $app->post('/usuarios/:id/mensajes/:mensaje(/)', function ($id, $mensaje) use ($app, $pdo) {
    
        if ($app->request()->post('access_token')) {
    
            if (curlCheckToken($app->request()->post('access_token') ,'mensaje')) {
    
                $stmt = $pdo->prepare("SELECT * FROM mensajes WHERE usuario_id = :id AND id = :mensaje");
                $stmt->bindParam(':id', $id, PDO::PARAM_INT);
                $stmt->bindParam(':mensaje', $mensaje, PDO::PARAM_INT);
                $stmt->execute();
                $resultado = $stmt->fetchAll(PDO::FETCH_CLASS, "Mensaje");
    
                if ($resultado) {
                    echo json_encode($resultado);
                }
                else {
                    echo json_encode(array(
                        'error' => 'No existe el mensaje solicitado'
                    ));
                }
            }
        }
    });
    
    $app->run();
    
    

    Funcion curl:

    <?php
    /**
     * Realiza la conexion curl para comprobar si el token es valido en la api para el scope solicitado
     * $token - Token a comprobar
     * $scope - permisos para el usuario. Cada metodo de nuestra api va a tener su propio scope: en este caso los posibles valores son "usuarios", "usuario", "mensajes", "mensaje"
     */
    function curlCheckToken($token_value, $scope = null) {
    
        $ch = curl_init();
        //Url al servidor Oauth
        curl_setopt($ch, CURLOPT_URL, "http://localhost/oauth-demo/response.php");
        curl_setopt($ch, CURLOPT_HEADER, 0);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, 'access_token=' . $token_value . '&scope=' . urlencode($scope));
        //Json response
        $raw_data = curl_exec($ch);
        curl_close($ch);
    
        $data = json_decode($raw_data);
        /**
         * Procesamos la respuesta de curlhttp://localhost/api-slimframework/listado/
         * Si en el array devuelto se encuentra la clave success con valor true es que hemos tenido exito
         * Si no, mostramos los errores que nos devuelve la api
         */
        if (isset($data->success) AND $data->success == true) {
    
            return true;
        }
        else {
    
            echo $raw_data;
        }
    }
    
    

    Clases usadas para formatear los datos que se extraigan:

    <?php
    
    class Usuarios {
    
        public $usuarios ;
    
        function __construct($usuarios = null) {
            $this->usuarios = $usuarios;
        }
    
    }
    
    class Usuario {
    
        public $id;
        public $nombre;
    
    }
    
    class Mensajes {
    
        public $mensajes ;
    
        function __construct($mensajes = null) {
            $this->mensajes = $mensajes;
        }
    
    }
    
    class Mensaje {
    
        public $id;
        public $texto;
    
    }
    

    Comprobación del token:

    <?php
    
    /**
     * En este fichero se chequea que el token y el scope, y duelve si es valido o no, y en caso de que no el porqué
     */
    require 'vendor/autoload.php';
    
    $dsn = 'mysql:host=localhost;dbname=bdname';
    $username = "user";
    $password = "password";
    
    $storage = new OAuth2\Storage\Pdo(array(
        'dsn' => $dsn,
        'username' => $username,
        'password' => $password
    ));
    
    
    $config = array();
    $server = new OAuth2\Server($storage, $config);
    
    $request = OAuth2\Request::createFromGlobals();
    $response = new OAuth2\Response();
    
    $scope = $_POST['scope'];
    
    //Aquí es donde comprobamos si con los datos envíados tenemos acceso al recurso
    if (!$server->verifyResourceRequest($request, $response, $scope)) {
    
      // En caso de error, se mostrará los detalles aquí
      $response->send();
      die;
    }
    
    //Respuesta en caso de éxito
    echo json_encode(array('success' => true));
    
    

    Como se puede ver, cada uno de los métodos básicamente comprueba mediante CURL que el token de acceso tiene permisos para cada scope, si todo va bien conecta a la base de datos y extrae la info requerida, en caso contrario lanzará un error con una descripción de porque ha fallado( Token expirado, Token sin permisos para ese método o no hay datos disponibles según sea el caso).

  • Usando Oauth 2 junto con Slim Framework para crear API segura: Parte 1

    malakana     28 septiembre, 2015     Sin comentario     General, Oauth 2, Slim Framework

    En este primer post sobre como crear una api segura usando Slim Framework y un servidor Oauth 2 para php, vamos a tratar el sistema de generación de access token para que los usuarios a los que se le de permiso puedan acceder a nuestra api.

    En este ejemplo vamos a necesitar crear en nuestra Base de datos las siguientes tablas necesarias para trabajar con Oauth 2

    CREATE TABLE oauth_clients (client_id VARCHAR(80) NOT NULL, client_secret VARCHAR(80), redirect_uri VARCHAR(2000) NOT NULL, grant_types VARCHAR(80), scope VARCHAR(100), user_id VARCHAR(80), CONSTRAINT clients_client_id_pk PRIMARY KEY (client_id));
    CREATE TABLE oauth_access_tokens (access_token VARCHAR(40) NOT NULL, client_id VARCHAR(80) NOT NULL, user_id VARCHAR(255), expires TIMESTAMP NOT NULL, scope VARCHAR(2000), CONSTRAINT access_token_pk PRIMARY KEY (access_token));
    CREATE TABLE oauth_authorization_codes (authorization_code VARCHAR(40) NOT NULL, client_id VARCHAR(80) NOT NULL, user_id VARCHAR(255), redirect_uri VARCHAR(2000), expires TIMESTAMP NOT NULL, scope VARCHAR(2000), CONSTRAINT auth_code_pk PRIMARY KEY (authorization_code));
    CREATE TABLE oauth_refresh_tokens (refresh_token VARCHAR(40) NOT NULL, client_id VARCHAR(80) NOT NULL, user_id VARCHAR(255), expires TIMESTAMP NOT NULL, scope VARCHAR(2000), CONSTRAINT refresh_token_pk PRIMARY KEY (refresh_token));
    CREATE TABLE oauth_users (username VARCHAR(255) NOT NULL, password VARCHAR(2000), first_name VARCHAR(255), last_name VARCHAR(255), CONSTRAINT username_pk PRIMARY KEY (username));
    CREATE TABLE oauth_scopes (scope TEXT, is_default BOOLEAN);
    CREATE TABLE oauth_jwt (client_id VARCHAR(80) NOT NULL, subject VARCHAR(80), public_key VARCHAR(2000), CONSTRAINT jwt_client_id_pk PRIMARY KEY (client_id));
    

    Además insertaremos un usuario válido en la tabla oauth_clients con el que haremos las pruebas de accesso

    INSERT INTO `oauth_clients` (`client_id`, `client_secret`, `redirect_uri`) VALUES ('prueba', 'prueba', 'http://url.redireccion.com');
    

    Para empezar con el código, cargamos las librerias Oauth 2 y configuramos PDO como Storage de nuestro sistema de acceso seguro a la api

    require 'vendor/autoload.php';
    
    $dsn = 'mysql:host=localhost;dbname=bdname';
    $username = "user";
    $password = "password";
    
    $storage = new OAuth2\Storage\Pdo(array(
        'dsn' => $dsn,
        'username' => $username,
        'password' => $password
    ));
    

    Realizamos unas configuraciones en nuestro servidor Oauth para que con la petición se nos devuelva directamente un token de acceso, esto lo logramos con el parámetro allow_implicit igual a true (más info), el parámetro enforce_state es obligatorio ponerlo a false según la documentación, si no, nos devolverá un error al lanzar la petición (más info)

    $config = array(
        'enforce_state' => false,
        'allow_implicit' => true,
    );
    

    Instanciamos el servidor con todo lo configurado previamente

    $server = new OAuth2\Server($storage, $config);
    

    Configuramos el scope(ambito donde daremos permiso al usuario) por defecto y todos los scope soportados, para la variable $defaultScope en este caso le he dado un valor directamente, lo suyo seria que en la petición le pasasemos como parñametro este valor, para hacerlo más funcional, pero como es un ejemplo nos vale así. Usamos la clase Scope() y el metodo setScopeUtil para pasar al servidor la configuración

    $defaultScope = 'scope1';
    $supportedScopes = array(
        'scope1',
        'scope2',
        'scope3'
    );
    
    $scope = new OAuth2\Scope(array(
        'default_scope' => $defaultScope,
        'supported_scopes' => $supportedScopes
    ));
    
    $server->setScopeUtil($scope);
    

    Como vamos a trabajar con un tipo de autorización Implicit para que nos devuelva el token directamente configuramos el servidor con el grantType AuthorizationCode que es el necesario para estos casos

    $grantType = new OAuth2\GrantType\AuthorizationCode($storage);
    $server->addGrantType($grantType);
    
    $request = OAuth2\Request::createFromGlobals();
    $response = new OAuth2\Response();
    

    Usamos la funcion validateAuthorizeRequest pasándole el request y el response.
    Si la función encuentra algún problema lanzará el error y se parará la ejecución del código en caso contrario

    if (!$server->validateAuthorizeRequest($request, $response)) {
        $response->send();
        die;
    }
    

    Muestra un form que nos pregunta si queremos autorizar al cliente y generar así un token de acceso para que este pueda acceder a la api. Si respondemos que sí

    if (empty($_POST)) {
      exit('
    <form method="post">
      <label>Quiere autoriar al cliente?</label><br />
      <input type="submit" name="authorized" value="yes">
      <input type="submit" name="authorized" value="no">
    </form>');
    }
    

    $is_authorized controla la respuesta en el form y lo pasa como parámetro a la función que genera el token de acceso, el valor necesario es yes
    $server->handleAuthorizeRequest genera el token y lo guarda en la base de datos

    $is_authorized = ($_POST['authorized'] === 'yes');
    $server->handleAuthorizeRequest($request, $response, $is_authorized);
    

    Esta parte es solo para mostrar el codigo en pantalla, una vez salvado en la BD

    $is_authorized = ($_POST['authorized'] === 'yes');
    $server->handleAuthorizeRequest($request, $response, $is_authorized);
    
    if ($is_authorized) {
    
        $token = substr($response->getHttpHeader('Location') , strpos($response->getHttpHeader('Location') , 'access_token=') + 13, 40);
    
        echo $token;
    
    }
    
    

    Código completo del ejemplo:

    require 'vendor/autoload.php';
    
    $dsn = 'mysql:host=localhost;dbname=bdname';
    $username = "user";
    $password = "password";
    
    $storage = new OAuth2\Storage\Pdo(array(
        'dsn' => $dsn,
        'username' => $username,
        'password' => $password
    ));
    
    $config = array(
        'enforce_state' => false,
        'allow_implicit' => true,
    );
    
    $server = new OAuth2\Server($storage, $config);
    
    $defaultScope = 'scope1';
    $supportedScopes = array(
        'scope1',
        'scope2',
        'scope3'
    );
    
    $scope = new OAuth2\Scope(array(
        'default_scope' => $defaultScope,
        'supported_scopes' => $supportedScopes
    ));
    
    $server->setScopeUtil($scope);
    
    $grantType = new OAuth2\GrantType\AuthorizationCode($storage);
    $server->addGrantType($grantType);
    
    $request = OAuth2\Request::createFromGlobals();
    $response = new OAuth2\Response();
    
    if (!$server->validateAuthorizeRequest($request, $response)) {
        $response->send();
        die;
    }
    
    if (empty($_POST)) {
      exit('
    <form method="post">
      <label>Quiere autoriar al cliente?</label><br />
      <input type="submit" name="authorized" value="yes">
      <input type="submit" name="authorized" value="no">
    </form>');
    }
    
    $is_authorized = ($_POST['authorized'] === 'yes');
    $server->handleAuthorizeRequest($request, $response, $is_authorized);
    
    $is_authorized = ($_POST['authorized'] === 'yes');
    $server->handleAuthorizeRequest($request, $response, $is_authorized);
    
    if ($is_authorized) {
    
        $token = substr($response->getHttpHeader('Location') , strpos($response->getHttpHeader('Location') , 'access_token=') + 13, 40);
    
        echo $token;
    
    }
    
    

    Para probar este ejemplo y generar un access token solo cargaremos la url donde se encuentra el código y pasaremos en la url los parámetros user_id y response type con el valor token que es el apropiado para el grant_type implicit . Ejemplo: http://urldelgeneradordetokens/file.php?response_type=token&client_id=prueba

    En la segunda parte del post, crearemos una pequeña api usando Slimframework y accederemos de manera segura a los métodos con los access token generados en esta primera parte de la creación de la api.

    Fuente de documentación: Bshaffer – Oauth2 Server for php

Autor

malakanaMalakanaDesarrollador web freelance, aprendedor

  • F
  • t
  • G
  • l

Temas

  • Doctrine 2 (1)
  • General (6)
  • Oauth 2 (2)
  • Slim Framework (3)
  • Zend Framework 2 (3)

Busca

Diego Domínguez Blog / ProgramadorMalaga.com