No sólo de novedades y opiniones puede alimentarse el blog así que toca volver al código.

Existen situaciones en las que el uso de las estructuras de URL que provee Magento por defecto puede no ser útil a lo que buscamos como funcionalidad. Supongamos que tampoco nos sirve el crear una reescritura (URL rewrite desde el backend).

Cuando ese es el caso, tenemos la opción de crear un nuevo router para manejar los requests y llevarlos hacia las clases que deban ocuparse.

En este post me voy a enfocar en eso mismo. Voy a crear un router que reciba peticiones y lo envíe hacia los controllers que corresponda, pero con algunos requerimientos que quiero satisfacer:

  • Como Comprador quiero acceder a los datos de una entidad a través de una URL como ésta: dominio.com/entidades/detalle_entidad. Pensemos que estoy hablando de comercios o vendedores en un marketplace, la URL resultante debería ser dominio.com/comercios/comercio-de-cercania.
  • Como Administrador quiero poder configurar la ruta de mi URL para esa entidad. Es decir, que hoy puedo usar comercios, mañana negocios o en otro proyecto otro nombre.

No voy a mostrar cómo crear un módulo desde 0. Partimos del supuesto de tener un módulo vacío ya creado y habilitado (en mi caso el módulo se llama Barbanet_Merchants). Es decir, el módulo ya tiene estos archivos creados:

El primer paso será definir mi router en etc/frontend/di.xml.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\App\RouterList">
        <arguments>
            <argument name="routerList" xsi:type="array">
                <item name="merchants" xsi:type="array">
                    <item name="class" xsi:type="string">Barbanet\Merchants\Controller\Router</item>
                    <item name="disable" xsi:type="boolean">false</item>
                    <item name="sortOrder" xsi:type="string">50</item>
                </item>
            </argument>
        </arguments>
    </type>
</config>

Y ahora voy a escribir la clase que va a manejar el routeo. Como se puede ver en el XML, la clase estará en Controller/Router.php.

declare(strict_types=1);

namespace Barbanet\Merchants\Controller;

use Barbanet\Merchants\Helper\Data;
use Magento\Framework\App\Action\Forward;
use Magento\Framework\App\ActionFactory;
use Magento\Framework\App\ActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\ResponseInterface;
use Magento\Framework\App\RouterInterface;

/**
 * Class Router
 */
class Router implements RouterInterface
{
    /**
     * @var ActionFactory
     */
    private $actionFactory;

    /**
     * @var Data
     */
    private $helper;

    /**
     * @var ResponseInterface
     */
    private $response;

    /**
     * Router constructor.
     *
     * @param ActionFactory $actionFactory
     * @param Data $helper
     * @param ResponseInterface $response
     */
    public function __construct(
        ActionFactory $actionFactory,
        Data $helper,
        ResponseInterface $response
    ) {
        $this->actionFactory = $actionFactory;
        $this->helper = $helper;
        $this->response = $response;
    }

    /**
     * @param RequestInterface $request
     * @return ActionInterface|null
     */
    public function match(RequestInterface $request): ?ActionInterface
    {
        /**
         * Obtengo la URL sin el dominio.
         */
        $url = trim($request->getPathInfo(), '/');

        /**
         * Convierto en array las distinas partes del path (ya, como dije, sin el dominio).
         */
        $identifier = explode('/', $url);

        /**
         * Guardo el total de partes del path obtenido para usarlo luego en validaciones.
         */
        $routeSize = count($identifier);

        /**
         * Controlo que haya un valor o que no sea superior al máximo que se que puede ser válido.
         */
        if (!$routeSize || $routeSize >= 3) {
            return null;
        }

        /**
         * Obtengo (ya explicaré mejor esta parte) la primera parte del path que se supone quiero usar.
         */
        $routerUrl = $this->helper->getRouterUrl();

        /**
         * Defino qué módulo y controller se va usar por defecto. Podría definir un action si me fuera útil.
         */
        $module = 'merchants';
        $controller = 'index';
        $continue = true;

        /**
         * Reviso que los parámetros aplican para el action que va a listar los comercios.
         * Lo que espero ejecutar aquí es el caso de www.dominio.com/comercios
         */
        if ($routeSize == 1 && $identifier[0] == $routerUrl) {
            $action = 'index';
            $params = [];
            $request
                ->setModuleName($module)
                ->setControllerName($controller)
                ->setActionName($action)
                ->setParams($params)
                ->setDispatched(true);
            $continue = false;
        }

        /**
         * Action que mostrará el detalle. Aquí debo recibir dos valores en el request path.
         * Lo que espero ejecutar aquí es el caso de www.dominio.com/comercios/comercio-de-cercania
         */
        if ($routeSize == 2 && $identifier[0] == $routerUrl) {
            $action = 'details';
            $params = [
                'merchant' => $identifier[1]
            ];
            $request
                ->setModuleName($module)
                ->setControllerName($controller)
                ->setActionName($action)
                ->setParams($params)
                ->setDispatched(true);
            $continue = false;
        }

        /**
         * Si hubo match en algún caso, haré el forward.
         */
        if (!$continue) {
            return $this->actionFactory->create(
                Forward::class,
                [
                    'request' => $request
                ]
            );
        }

        /**
         * No hubo match con ninguna regla, el request sigue su camino hacia el próximo router.
         * Eventualmente podría llegar al 404.
         */
        return null;
    }
}

Posiblemente se deba a que escribí (y probé) el código, pero creería que se explica por si solo (además de los comentarios). El método match es el encargado en los routers de procesar el request y hacer su magia (versión super resumida de lo que pasa).

Ahora, ya que mi módulo tendrá controllers, necesito también definir etc/frontend/routes.xml.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="merchants" frontName="merchants">
            <module name="Barbanet_Merchants" />
        </route>
    </router>
</config>

Ahora creamos los controllers que van a recibir y gestionar esos requests. Primero Controller/Index/Index.php, que será el encargado de listar todos los comercios.

declare(strict_types=1);

namespace Barbanet\Merchants\Controller\Index;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\ResponseInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;

/**
 * Class Index
 */
class Index extends Action implements HttpGetActionInterface
{
    /**
     * @var PageFactory
     */
    private $pageFactory;

    /**
     * @param Context $context
     * @param PageFactory $pageFactory
     */
    public function __construct(
        Context $context,
        PageFactory $pageFactory
    ) {
        parent::__construct($context);

        $this->pageFactory = $pageFactory;
    }

    /**
     * @return ResponseInterface|ResultInterface|Page
     */
    public function execute()
    {
        return $this->pageFactory->create();
    }
}

Y luego el controller que servirá como vista detalle de cada uno de ellos: Controller/Index/Details.php.

declare(strict_types=1);

namespace Barbanet\Merchants\Controller\Index;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\ResponseInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;

/**
 * Class Index
 */
class Details extends Action implements HttpGetActionInterface
{
    /**
     * @var PageFactory
     */
    private $pageFactory;

    /**
     * @param Context $context
     * @param PageFactory $pageFactory
     */
    public function __construct(
        Context $context,
        PageFactory $pageFactory
    ) {
        parent::__construct($context);

        $this->pageFactory = $pageFactory;
    }

    /**
     * @return ResponseInterface|ResultInterface|Page
     */
    public function execute()
    {
        /**
         * Get the params that were passed from our Router
         */
        $merchant = $this->getRequest()->getParam('merchant', null);

        /** @var Page $page */
        $page = $this->resultFactory->create(ResultFactory::TYPE_PAGE);

        /** @var Details $block */
        $block = $page->getLayout()->getBlock('barbanet.merchants.details');
        $block->setData('merchant_name', $merchant);

        return $page;
    }
}

Lo que hago en el método execute es capturar el parámetro merchant que se nos envía desde el router. Luego tomo el bloque que usaré en mi layout (más adelante la definición) y le asigno el valor que recibí del router a la variable merchant_name, que intentaré usar en mi phtml.

Vamos a definir mis layouts. Vamos primero con view/frontend/layout/merchants_index_index.xml.

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="page.main.title">
            <action method="setPageTitle">
                <argument translate="true" name="title" xsi:type="string">Merchants</argument>
            </action>
        </referenceBlock>
    </body>
</page>

Aquí no estoy agregando ninguna lógica específica. Simplemente defino el título para que se vea luego que funciona.

Ahora vamos con view/frontend/layout/merchants_index_details.xml, el layout de la página que mostrará el detalle de cada comercio.

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="page.main.title">
            <action method="setPageTitle">
                <argument translate="true" name="title" xsi:type="string">Merchant Details</argument>
            </action>
        </referenceBlock>
        <referenceContainer name="content">
            <block class="Barbanet\Merchants\Block\Details" name="barbanet.merchants.details" template="Barbanet_Merchants::details.phtml" />
        </referenceContainer>
    </body>
</page>

En este layout he cambiado el título y agrego un bloque que será el que se definirá en base a lo que suceda entre el request, el router y el controler. Turno para el phtml que acabo de indicar en el layout.

<h2><?php echo $block->getData('merchant_name'); ?></h2>
<ul>
    <li>Address: Avenida Siempreviva 742</li>
    <li>Tel: 555-5555</li>
</ul>

Como se ve, no estoy usando nada del otro mundo. A los fines del ejemplo, sólo voy a mostrar el nombre del comercio en base a la URL.

Este bloque podría no ser realmente necesario ya que podríamos usar la clase Template directamente, pero como necesito luego más métodos, incluyo mi bloque dummy en este ejemplo. Vamos con Block/Details.php (el que está definido en el último layout).

declare(strict_types=1);

namespace Barbanet\Merchants\Block;

use Magento\Framework\View\Element\Template;

class Details extends Template
{

}

De nuevo, podrían no tener el bloque y definir sólo a la clase Template en el layout, y funcionará lo mismo.

Hasta acá, casi, podríamos estar hablando de un router común y corriente, con alguna mínima lógica para redirigir los requests. Esto me ha permitido cumplir con el primer requerimiento.

Ya tengo la página que listaría múltiples valores. El list.

Y también la página de detalle.

Vayamos entonces al segundo requerimiento, el cual es la excusa de este post.

En mi Router, en el método match, tengo la siguiente línea.

$routerUrl = $this->helper->getRouterUrl();

Y luego cuando me toca validar la URL, vimos que la validación hacía algo como esto:

if ($routeSize == 1 && $identifier[0] == $routerUrl) {

Si en lugar de hacer la llamada a ese método y en esa variable definíamos con algo como ‘comercios’, en lugar de responder cuando la url es dominio.com/merchant, lo haría sólo si fuera dominio.com/comercios.

Recordemos ahora el segundo requerimiento: eso debe ser configurable por el Administrador de la tienda.

Por esto último es que he creado una configuración que puede ser gestionada. Lo primero fue definir etc/acl.xml.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
    <acl>
        <resources>
            <resource id="Magento_Backend::admin">
                <resource id="Barbanet_Merchants::configuration" title="Merchants Configuration" sortOrder="69" />
            </resource>
        </resources>
    </acl>
</config>

Luego, como ya vimos en el pasado, vamos a crear el archivo etc/adminhtml/system.xml.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <section id="merchants" translate="label" type="text" sortOrder="9999" showInDefault="1" showInWebsite="1" showInStore="1">
            <label>Merchants</label>
            <tab>merchants</tab>
            <resource>Barbanet_Merchants::configuration</resource>
            <group id="router" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
                <label>Router</label>
                <field id="url" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Router URL</label>
                </field>
            </group>
        </section>
    </system>
</config>

Aquí sólo he agregado un campo de texto, que aparecerá en un tab creado previamente. Si por algún motivo copian y pegan el código, el valor de <tab> debe coincidir con alguno que posean.

Dado que el valor del campo que definimos será usado por el router, no puedo dejar librado al azar que alguien defina un valor antes que el módulo esté en funcionamiento. Para evitar ese problema, definimos valores por defecto a través del archivo etc/config.xml.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <merchants>
            <router>
                <url>merchants</url>
            </router>
        </merchants>
    </default>
</config>

Si todo se hizo bien, y luego de refrescar cache, deberíamos tener la opción de configuración disponile.

Por último, voy a usar un helper para acceder a la configuración desde el Router. El archivo (odiado, amado y muy usuado en cualquiera de los dos casos) es Helper/Data.php.

declare(strict_types=1);

namespace Barbanet\Merchants\Helper;

use Magento\Framework\App\Helper\AbstractHelper;
use Magento\Store\Model\ScopeInterface;

class Data extends AbstractHelper
{
    /**
     * @param $path
     * @param string $store
     * @return mixed
     */
    protected function getConfig($path, $store = ScopeInterface::SCOPE_STORE)
    {
        return $this->scopeConfig->getValue(
            'merchants/' . $path,
            $store
        );
    }

    /**
     * @return mixed
     */
    public function getRouterUrl()
    {
        return $this->getConfig('router/url');
    }
}

Terminó la etapa de código. Si todo lo anterior es correcto, debería cambiar el valor de la configuración y todo habría de funcionar. Lo voy a cambiar por… ¿Comercios, Negocios, Vendedores, Proveedores?. Que sea Vendedores.

Configuración guardada. Toca volver al frontend y probar la primera URL: dominio.com/vendedores.

Y ahora vayamos por la página detalle.

También funcionó.

Ahora si, requerimientos cumplidos.