Blog

  • Redirect disabled product pages instead of 404 page in Magento 2

    Redirect disabled product pages instead of 404 page in Magento 2

    In this blog post I’ll show how to implement a Magento 2 Extension which allows to redirect from disabled product url to

    • a custom url
    • assigned category of a highest level as next fallback if no custom url is entered on product level (custom attribute)
    • home page as last fallback if no categories found

    instead of showing a 404 Not Found page.

    We will call the extension N98_DisabledProductRedirect

    Extension Code

    Basic files

    registration.php

    <?php
    
    use Magento\Framework\Component\ComponentRegistrar;
    
    ComponentRegistrar::register(
        ComponentRegistrar::MODULE,
        'N98_DisabledProductRedirect',
        __DIR__
    );

    etc/module.xml

    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
        <module name="N98_DisabledProductRedirect" setup_version="1.0.0">
            <sequence>
                <module name="Magento_Catalog"/>
            </sequence>
        </module>
    </config>
    

    Plugin on Product View frontend Controller

    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\Catalog\Controller\Product\View">
            <plugin name="disabled_product_redirect_plugin" type="N98\DisabledProductRedirect\Plugin\ProductRedirectPlugin" />
        </type>
    </config>
    

    Plugin/ProductRedirectPlugin.php

    <?php
    declare(strict_types=1);
    
    namespace N98\DisabledProductRedirect\Plugin;
    
    use Magento\Catalog\Api\ProductRepositoryInterface;
    use Magento\Catalog\Model\Product;
    use Magento\Framework\Controller\Result\RedirectFactory;
    use Magento\Framework\Exception\NoSuchEntityException;
    use N98\DisabledProductRedirect\Service\GetRedirectUrlForDisabledProductService;
    
    class ProductRedirectPlugin
    {
        /**
         * @var ProductRepositoryInterface
         */
        private ProductRepositoryInterface $productRepository;
    
        /**
         * @var RedirectFactory
         */
        private RedirectFactory $redirectFactory;
    
        /**
         * @var GetRedirectUrlForDisabledProductService
         */
        private GetRedirectUrlForDisabledProductService $getRedirectUrlForDisabledProductService;
    
        /**
         * ProductRedirectPlugin constructor.
         *
         * @param ProductRepositoryInterface $productRepository
         * @param RedirectFactory $redirectFactory
         * @param GetRedirectUrlForDisabledProductService $getRedirectUrlForDisabledProductService
         */
        public function __construct(
            ProductRepositoryInterface $productRepository,
            RedirectFactory $redirectFactory,
            GetRedirectUrlForDisabledProductService $getRedirectUrlForDisabledProductService
        ) {
            $this->productRepository = $productRepository;
            $this->redirectFactory = $redirectFactory;
            $this->getRedirectUrlForDisabledProductService = $getRedirectUrlForDisabledProductService;
        }
    
        /**
         * Main plugin method.
         *
         * @param \Magento\Catalog\Controller\Product\View $subject
         * @param callable $proceed
         * @return \Magento\Framework\Controller\Result\Redirect
         * @throws \Magento\Framework\Exception\LocalizedException
         */
        public function aroundExecute($subject, callable $proceed)
        {
            $productId = $subject->getRequest()->getParam('id');
    
            try {
                $product = $this->productRepository->getById($productId);
                if ((int)$product->getStatus() === Product\Attribute\Source\Status::STATUS_DISABLED) {
                    $redirectUrl = $this->getRedirectUrlForDisabledProductService->execute($product);
    
                    $resultRedirect = $this->redirectFactory->create();
                    $resultRedirect->setUrl($redirectUrl);
                    $resultRedirect->setHttpResponseCode(302);
                    return $resultRedirect;
                }
            } catch (NoSuchEntityException $e) {
                return $proceed();
            }
    
            return $proceed();
        }
    }
    

    Service “Get Redirect URL for disabled product”

    Service/GetRedirectUrlForDisabledProductService.php

    <?php
    declare(strict_types=1);
    
    namespace N98\DisabledProductRedirect\Service;
    
    use Magento\Catalog\Api\Data\ProductInterface;
    use Magento\Store\Model\StoreManagerInterface;
    
    class GetRedirectUrlForDisabledProductService
    {
        /**
         * GetRedirectUrlForDisabledProductService constructor.
         *
         * @param StoreManagerInterface $storeManager
         */
        public function __construct(private readonly StoreManagerInterface $storeManager)
        {
        }
    
        /**
         * Get redirect URL for a disabled product
         *
         * @param ProductInterface $product
         * @return string
         * @throws \Magento\Framework\Exception\LocalizedException
         * @throws \Magento\Framework\Exception\NoSuchEntityException
         */
        public function execute(ProductInterface $product): string
        {
            // Check for a defined-per-product URL attribute
            $customRedirectUrl = $product->getData('redirect_url_if_disabled');
            if ($customRedirectUrl) {
                return $customRedirectUrl;
            }
    
            // Fallback to the highest-level category URL
            /** @var \Magento\Catalog\Model\ResourceModel\Category\Collection $categoryCollection */
            $categoryCollection = $product->getCategoryCollection();
            $categoryCollection->addAttributeToSelect('url_key')->load();
    
            $highestLevelCategory = null;
            foreach ($categoryCollection as $category) {
                if (!$highestLevelCategory || $category->getLevel() < $highestLevelCategory->getLevel()) {
                    $highestLevelCategory = $category;
                }
            }
    
            if ($highestLevelCategory) {
                /** @var \Magento\Catalog\Model\Category $highestLevelCategory */
                return $highestLevelCategory->getUrl();
            }
    
            // Default fallback URL (home page)
            return $this->storeManager->getStore()->getBaseUrl();
        }
    }
    

    Data Patch to create attribute “redirect_url_if_disabled”

    Setup/Patch/Data/AddRedirectUrlProductAttribute.php

    <?php
    declare(strict_types=1);
    
    namespace N98\DisabledProductRedirect\Setup\Patch\Data;
    
    use Magento\Catalog\Model\Product;
    use Magento\Eav\Setup\EavSetup;
    use Magento\Eav\Setup\EavSetupFactory;
    use Magento\Framework\Setup\ModuleDataSetupInterface;
    use Magento\Framework\Setup\Patch\DataPatchInterface;
    
    class AddRedirectUrlProductAttribute implements DataPatchInterface
    {
        /**
         * AddRedirectUrlProductAttribute constructor.
         *
         * @param EavSetupFactory $eavSetupFactory
         * @param ModuleDataSetupInterface $moduleDataSetup
         */
        public function __construct(
            private readonly EavSetupFactory          $eavSetupFactory,
            private readonly ModuleDataSetupInterface $moduleDataSetup
        ) {
        }
    
        /**
         * Add attribute "redirect_url_if_disabled" to products and assign to default attribute set
         *
         * @return void
         * @throws \Magento\Framework\Exception\LocalizedException
         * @throws \Magento\Framework\Validator\ValidateException
         */
        public function apply()
        {
            /** @var EavSetup $eavSetup */
            $eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
    
            $eavSetup->addAttribute(Product::ENTITY, 'redirect_url_if_disabled', [
                'type'       => 'text',
                'label'      => 'Redirect URL (if product is disabled)',
                'input'      => 'textarea',
                'required'   => false,
                'sort_order' => 100,
                'global'     => \Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface::SCOPE_STORE,
                'group'      => 'General',
            ]);
    
            $attributeSetId = $eavSetup->getDefaultAttributeSetId(Product::ENTITY);
            $attributeGroupId = $eavSetup->getAttributeGroupId(Product::ENTITY, $attributeSetId, 'General');
            $eavSetup->addAttributeToGroup(
                Product::ENTITY,
                $attributeSetId,
                $attributeGroupId,
                'redirect_url_if_disabled',
                100
            );
        }
    
        /**
         * @inheritDoc
         */
        public static function getDependencies()
        {
            return [];
        }
    
        /**
         * @inheritDoc
         */
        public function getAliases()
        {
            return [];
        }
    }
    

    Conclusion

    As you could see in the code, the module is pretty simple. For redirect code we used 302 but also 301 can be used or you can make it configurable via Store Configuration.

    Feel free to leave a comment.

    P.S. In your comment you can answer a quiz question: was the blog post featured image AI generated or created manually? 😊

  • Celebrating 5 Years of ddev

    Celebrating 5 Years of ddev

    Today marks a significant milestone for us at Valantic CEC (formerly known as netz98) as we celebrate five fruitful years of utilizing ddev. Our journey with this remarkable tool has not only streamlined our development processes but also brought about a transformation in how we approach our projects. As we reflect on the past five years, it’s a perfect opportunity to share insights into how ddev has benefited our team, enhanced our workflows, and contributed to our overall success.

    A Game Changer in Development

    When we first adopted ddev, our development landscape was cluttered and chaotic. Developers struggled to set up their environments consistently, leading to inefficiencies and frustrations. We needed a solution that would simplify our workflow and empower our team to focus on what really matters: delivering high-quality e-commerce solutions for our clients.

    ddev emerged as that solution. As a powerful local development environment, it allows developers to quickly spin up and manage complex applications with little configuration required. The ease of use and robust capabilities made it an instant favorite within our team. Our developers can now set up projects with just a few commands, eliminating the days spent wrestling with environment configurations.

    Supporting the Community

    Our commitment to ddev extends beyond our internal use; we actively support the tool and its community. We maintain the official OpenSearch addon, which enhances ddev’s capabilities and ensures that our development environment is equipped with the latest features. Additionally, we’re proud to provide an n8n addon, allowing for seamless integration of workflows and automation in our projects. You can check it out here: ddev-n8n.

    Moreover, our contributions to ddev are not just about the tools we support. Over the years, we have shared our knowledge through various articles and demonstrations, making it easier for other developers to adopt ddev in their own workflows. For instance, our demo setup for Magento Open Source, available at ddev-magento-demo, serves as a valuable resource for those looking to get started with ddev and Magento.

    Streamlining Development Processes

    One of the most significant impacts of ddev on our organization has been its ability to streamline developer setups. By establishing a standardized development environment, we have drastically reduced onboarding times for new team members. They no longer need to spend hours configuring their local machines; instead, they can dive straight into development. This uniformity not only boosts productivity but also enhances collaboration, as everyone works within the same framework.

    Furthermore, the flexibility offered by ddev facilitates the use of numerous services tailored to our clients’ needs. Whether it’s managing databases or running background jobs, our developers can configure the environment to suit the specific demands of a project with ease.

    We can easily transfer configurations for several commonly used services between projects. In the meantime, most of the developers are experienced enough to modify or extend the setup.

    A Heartfelt Thanks to Randy Fay

    As we celebrate our fifth anniversary with ddev, we want to extend our heartfelt congratulations to Randy Fay, the maintainer of this exceptional tool. Your vision and commitment have made ddev a cornerstone of our development processes. Knowing that we have a dedicated maintainer behind ddev gives us confidence in its ongoing evolution. We genuinely appreciate the hard work and dedication that goes into maintaining such a powerful tool.

    We also do not forget Stas Zhuk for all the help as Co-Maintainer of the project.

    Looking Ahead

    As we move forward, we remain excited about the possibilities that ddev presents. The landscape of e-commerce development is constantly evolving, and so are the needs of our clients. Embracing tools like ddev allows us to stay agile, respond to market changes, and ultimately deliver better solutions for our customers.
    For those who have yet to explore what ddev can offer, I encourage you to give it a try. Experience the benefits of a streamlined development process, enhanced collaboration, and a supportive community. With ddev in your toolkit, tackling complex e-commerce challenges becomes not just achievable but enjoyable.

    In conclusion, let us celebrate not only our past with ddev but also the exciting future ahead. As we continue to leverage its capabilities, we invite you to join us on this journey. Together, we can build efficient, scalable, and powerful e-commerce solutions that empower businesses to thrive in the digital marketplace. Here’s to many more years of innovation and success!

  • Building and Deploying with Adobe App Builder

    Building and Deploying with Adobe App Builder

    Adobe App Builder is an innovative platform designed to streamline the development and integration of custom applications for Adobe Experience Cloud. Leveraging this robust tool, developers can create tailored solutions that meet the unique needs of their business operations. In this blog post, we will explore the key features of Adobe App Builder, delve into its operational mechanics, and provide a step-by-step guide on building and deploying projects using the aio-cli tool.

    What is Adobe App Builder?

    Adobe App Builder is a cloud-native extensibility platform for Adobe Experience Cloud. It enables developers to build and deploy custom web apps, microservices, and automation workflows. The platform provides a comprehensive suite of development tools, including a CLI, SDKs, and a flexible runtime environment.

    Key Features

    1. Seamless Integration: Easily integrate with Adobe Experience Cloud products and services.
    2. Cloud-Native: Build scalable and reliable applications with cloud-native capabilities.
    3. Development Tools: Utilize a range of tools, including aio-cli, to streamline the development process.
    4. Flexibility: Create custom solutions that can adapt to specific business requirements.

    How It Works

    Adobe App Builder leverages Adobe I/O Runtime, a serverless platform, to execute code in response to events. This ensures that your applications are both scalable and cost-effective, as you only pay for the compute resources you use.

    Core Components

    • Adobe I/O Runtime: A serverless platform for running custom code.
    • aio-cli: A command-line interface for managing projects and deployments.
    • SDKs: Software Development Kits for various programming languages to interact with Adobe services.

    Getting Started with Adobe App Builder

    To get started with Adobe App Builder, you need to set up your development environment and create your first project. Follow these steps to get started:

    Prerequisites

    • Node.js (version 12 or later)
    • aio-cli (Adobe I/O CLI)

    Installation

    1. Install aio-cli:
    npm install -g @adobe/aio-cli
    1. Log in to Adobe I/O CLI:
    aio login
    1. Create a new project:
    aio app init my-app

    Building and Deploying Your Project

    Once you have initialized your project, you can proceed to build and deploy it using aio-cli. Here’s a step-by-step guide:

    1. Navigate to your project directory:
    cd my-app
    1. Build the project:
    aio app build
    1. Deploy the project:
    aio app deploy

    Code Samples

    Adobe provides various code samples to help you get started with common tasks. You can find these samples in the official GitHub repository. These examples demonstrate how to integrate with different Adobe Experience Cloud services and build custom solutions.

    Benefits of Using Adobe App Builder

    1. Speed: Accelerate development cycles by leveraging pre-built integrations and cloud-native tools.
    2. Scalability: Effortlessly scale your applications to meet growing business demands.
    3. Cost-Effectiveness: Only pay for the resources you use, reducing overhead costs.
    4. Customization: Tailor applications to meet specific business requirements, enhancing operational efficiency.

    Conclusion

    Adobe App Builder is a powerful platform that simplifies the process of developing and deploying custom applications for Adobe Experience Cloud. By utilizing tools like aio-cli and the provided SDKs, developers can create scalable and flexible solutions that meet their specific business needs. With this guide, you should be well on your way to building and deploying your first Adobe App Builder project.

    This blog post describes only the absolute basics to start with Adobe App Builder.
    There is much more behind the scenes. We will publish additional posts for more topics in the future e.g. to deploy the App Builder application within a CI/CD environment.

    For more detailed information and resources, visit the Adobe App Builder documentation. Happy coding!

  • Run n98-magerun2 from everywhere

    Are you still copying the n98-magerun2.phar to your project root? There is a better way to run the tool. I created a small video to show you your options.

  • Create a GraphQL Mesh from scratch

    Create a GraphQL Mesh from scratch

    GraphQL Mesh is a powerful tool that allows you to use GraphQL query language, regardless of the source’s original format. It can be used with REST APIs, gRPC, SOAP, and more. In this blog post, we’ll explore how to set up and use GraphQL Mesh in your projects.

    Warning: The article requires a bit of NodeJS knowledge. The examples are tested with NodeJS 16.
    We also define a environment variable MAGENTO_ACCESS_TOKEN containing a a Magento Bearer Token. to access the API. If you don’t have a Magento system available the then replace it with another GraphQL endpoint and modify the examples here.

    Setting Up GraphQL Mesh

    To get started with GraphQL Mesh, you’ll first need to install the necessary dependencies. You can do this by adding the following to your package.json file:

    {
      "dependencies": {
        "@graphql-mesh/cli": "^0.82.35",
        "@graphql-mesh/graphql": "^0.22.7",
        "@graphql-mesh/openapi": "^0.24.13",
        "@graphql-mesh/transform-filter-schema": "^0.14.115",
        "@graphql-mesh/transform-prefix": "^0.93.1",
        "graphql": "^16.6.0"
      }
    }
    

    Once you’ve added these dependencies, you can install them using your package manager of choice.

    Install the dependencies with yarn or npm. We use yarn here:

    yarn install

    Writing a GraphQL Query

    With GraphQL Mesh, you can write queries that fetch data from multiple sources. Here’s an example of a GraphQL query that fetches product data and station data (yes, it’s a wild mix of APIs):

    {
      products(currentPage: 1, pageSize: 3, search:"24-MB01") {
        items {
          sku
          ... on SimpleProduct {
            my_calculated_price
          }
          price_range {
            minimum_price {
              final_price {
                currency
                value
              }
            }        
          }
        }
      }
      continents {
        code
      }
    }

    In this example, products and continents are two different queries that fetch data from different sources. The products query fetches product data, while continents fetches geographical information. Both queries are fired agains two different backend systems and APIs. As a developer you don’t see the backend systems. You see only one unified APIs. That’s one of the big advantages.

    Creating Resolvers

    Resolvers in GraphQL are functions that resolve data for your queries. In GraphQL Mesh, you can create resolvers that fetch data from your sources and return it in the format you need. Here’s an example of a resolver for the SimpleProduct type:

    import { Resolvers } from './.mesh'
    
    const resolvers: Resolvers = {
        SimpleProduct: {
            my_calculated_price: {
                selectionSet: /* GraphQL */`
                {
                    id,
                    price_range {
                        minimum_price {
                            final_price {
                                value
                            }
                        }        
                    }
                }
                `,
                resolve: async (root, _args, context, info) => {
                    console.log(root)
                    return (99.98 + root.price_range.minimum_price.final_price.value).toFixed(4)
                }
            }
        }
    }
    
    export default resolvers

    In this example, the my_calculated_price resolver fetches the price of a product and adds a fixed amount to it as example of a price calculation. We added a console.log here. If you query the products with the my_calculated_price field then you should see the value in your running console.

    Configuring GraphQL Mesh

    Finally, you’ll need to configure GraphQL Mesh to use your sources and resolvers. You can do this in the .meshrc.yaml file:

    serve:
      port: 5000
      browser: false
      playground: true
    sources:
      - name: Magento
        handler:
          graphql:
            endpoint: https://magento-demo.example.com/graphql
            operationHeaders:
              Authorization: Bearer {env.MAGENTO_ACCESS_TOKEN}
    
      - name: Countries
        handler:
          graphql:
            endpoint: https://countries.trevorblades.com
    
    transforms:
      - filterSchema:
          mode: wrap
          filters:
            - Query.!giftCardAccount
    
    additionalTypeDefs: |
      extend type SimpleProduct {
    
    
        sap_price: Float
      }
    
    
    additionalResolvers:
        - "./resolvers"

    In this configuration file, we’ve defined two sources: Magento and Countries. Each source has a GraphQL endpoint that GraphQL Mesh will fetch data from. We’ve also defined a transform that filters out the giftCardAccount query from the schema.

    The additionalTypeDefs section allows us to extend existing types with our own fields. In this case, we’re extending the SimpleProduct type with a my_calculated_price field.

    The additionalResolvers section is where we specify the path to our resolvers file.

    The library can do a lot of more stuff. One important feature is filtering the schema. Sometimes you don’t want publish everything in your schema. We then use a filter transformer to strip some meta data (giftCardAccount query in our example). It’s then not possible anymore to fetch this entities.

    You can find a brief documentation here: https://the-guild.dev/graphql/mesh

    Now you can try to run the mesh:

    yarn mesh build
    yarn mesh start
    // or dev mode -> yarn mesh dev

    This should build and run the mesh.

    If you open the printed url in your browser you should see a nice API playground.

    Conclusion

    GraphQL Mesh is a powerful tool that allows you to use GraphQL with any source, regardless of its original format. By setting up your dependencies, writing your queries, creating your resolvers, and configuring GraphQL Mesh, you can start fetching data from multiple sources with ease.

    Remember, the examples provided in this blog post are just that – examples. Your actual implementation may vary based on your specific needs and the sources you’re working with. However, these examples should provide a solid foundation for you to start working with GraphQL Mesh. Happy coding!

  • AJAX loading of related products in Magento 2

    AJAX loading of related products in Magento 2

    Introduction

    In some projects the amount of related products can be significant. In my case there were more than 300 related products and upsell products on some product detail pages. The consequence of this was a high time to first byte on product detail pages. In the mentioned project it was about 15 seconds for some of the products. So we searched for a possible optimization. We finally decided to implement asynchronous loading of product recommendations via AJAX. At the end we had a significant performance improvement and better user experience.

    In the following I will describe how to implement the solution on the example of related products on product detail pages. With some modifications this can also be used for upsell products on PDP, or even for crosssells in the Checkout Cart.

    In the following we use a sample extension called N98_AjaxProductRecommendations.

    The principle of the implementation

    First what needs to be done is remove the rendering of the related product block on product detail page. After that we replace the removed block with a custom placeholder block with own placeholder template. The template contains a placeholder <div> element and an initialization of a custom JS file. On document ready event an AJAX request is triggered to get the content of related products list and to put the content into the mentioned placeholder <div>. For the AJAX request we need a custom controller class and an abstract layout file. In the placeholder template we also build a check whether the product has related products and only if so, initialize the JS. This avoids unneeded requests for pages where there are no related products linked.

    Initialize module N98_AjaxProductRecommendations

    etc/module.xml :

    <?xml version="1.0"?>
    <!--
    /**
     * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
     */
    -->
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
        <module name="N98_AjaxProductRecommendations">
            <sequence>
                <module name="Magento_Catalog" />
                <module name="Magento_TargetRule" />
            </sequence>
        </module>
    </config>
    

    composer.json :

    {
        "name": "n98/ext.magento2.n98.ajax-product-recommendations",
        "description": "Switch to AJAX loading of product recommendations.",
        "type": "magento2-module",
        "license": [
            "proprietary"
        ],
        "authors": [
            {
                "name": "netz98 GmbH",
                "email": "magento@netz98.de"
            }
        ],
        "autoload": {
            "files": [
                "registration.php"
            ],
            "psr-4": {
                "N98\\AjaxProductRecommendations\\": ""
            }
        }
    }
    

    registration.php :

    <?php
    /**
     * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
     */
    
    \Magento\Framework\Component\ComponentRegistrar::register(
        \Magento\Framework\Component\ComponentRegistrar::MODULE,
        'N98_AjaxProductRecommendations',
        __DIR__
    );
    

    Route definition and controller

    etc/frontend/routes.xml :

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

    Controller/Ajax/RenderRelated.php :

    <?php
    /**
     * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
     *
     * @see PROJECT_LICENSE.txt
     */
    
    namespace N98\AjaxProductRecommendations\Controller\Ajax;
    
    use Magento\Catalog\Api\ProductRepositoryInterface;
    use Magento\Framework\App\Action\Action;
    use Magento\Framework\App\Action\Context;
    use Magento\Framework\App\ResponseInterface;
    use Magento\Framework\Controller\Result\Json;
    use Magento\Framework\Controller\Result\JsonFactory;
    use Magento\Framework\Registry;
    
    /**
     * Class RenderRelated
     *
     * @package N98\AjaxProductRecommendations\Controller\Ajax
     */
    class RenderRelated extends Action
    {
        const BLOCK_NAME_PRODUCT_RELATED_PRODUCTS = 'catalog.product.related';
        /**
         * @var JsonFactory
         */
        private $jsonFactory;
        /**
         * @var ProductRepositoryInterface
         */
        private $productRepository;
        /**
         * @var Registry
         */
        private $coreRegistry;
    
        /**
         * Render constructor.
         * @param Context $context
         * @param JsonFactory $jsonFactory
         * @param ProductRepositoryInterface $productRepository
         * @param Registry $coreRegistry
         */
        public function __construct(
            Context $context,
            JsonFactory $jsonFactory,
            ProductRepositoryInterface $productRepository,
            Registry $coreRegistry
        ) {
            parent::__construct($context);
            $this->jsonFactory = $jsonFactory;
            $this->productRepository = $productRepository;
            $this->coreRegistry = $coreRegistry;
        }
    
        /**
         * Execute action based on request and return result
         *
         * Note: Request will be added as operation argument in future
         *
         * @return \Magento\Framework\Controller\ResultInterface|ResponseInterface
         * @throws \Magento\Framework\Exception\NotFoundException
         */
        public function execute()
        {
            if (!$this->getRequest()->isAjax()) {
                $this->_forward('noroute');
                return;
            }
    
            $result = $this->jsonFactory->create();
    
            $productId = $this->getRequest()->getParam('product_id');
    
            if (!$productId) {
                return $this->setErrorResult(
                    $result,
                    __('Product recommendations could not be loaded.')
                );
            }
    
            try {
                $product = $this->productRepository->getById($productId);
                /*
                 * set current product in registry for this
                 * 2 keys in order it to be used in rendered blocks
                 */
                $this->coreRegistry->register('product', $product);
                $this->coreRegistry->register('current_product', $product);
    
                $this->_view->loadLayout(
                    ['default', 'n98_ajaxproductrecommendations_content_abstract'],
                    true,
                    true,
                    false
                );
                $layout = $this->_view->getLayout();
                $block = $layout->getBlock(self::BLOCK_NAME_PRODUCT_RELATED_PRODUCTS);
                if (!$block) {
                    return $this->setErrorResult(
                        $result,
                        __('Product recommendations could not be loaded.')
                    );
                }
                $output = $block->toHtml();
                $result->setData(
                    [
                        'output' => $output,
                        'success' => true
                    ]
                );
                return $result;
            } catch (\Exception $e) {
                return $this->setErrorResult(
                    $result,
                    __('Product recommendations could not be loaded.')
                );
            }
        }
    
        /**
         * Set error result
         *
         * @param Json $result
         * @param string $errorMessage
         * @return Json
         */
        private function setErrorResult(Json $result, $errorMessage)
        {
            $result->setData(
                [
                    'output' => $errorMessage,
                    'success' => false
                ]
            );
    
            return $result;
        }
    }
    

    Frontend Block, Layouts, Template, ViewModel and JS file

    Block/Placeholder/Related.php :

    <?php
    /**
     * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
     *
     * @see PROJECT_LICENSE.txt
     */
    
    namespace N98\AjaxProductRecommendations\Block\Placeholder;
    
    use Magento\Catalog\Block\Product\View as CatalogProductView;
    
    /**
     * Class Related
     *
     * @package N98\AjaxProductRecommendations\Block\Placeholder
     */
    class Related extends CatalogProductView
    {
        /**
         * @return string
         */
        public function getAjaxUrl()
        {
            // return relative url, base urls will be prefixed in js
            return 'n98_ajaxproductrecommendations/ajax/renderRelated';
        }
    }
    

    view/frontend/layout/catalog_product_view.xml :

    <?xml version="1.0"?>
    <!--
    /**
     * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
     *
     * @see PROJECT_LICENSE.txt
     */
    -->
    <page layout="1column" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
        <body>
            <referenceContainer name="content.aside">
                <block class="N98\AjaxProductRecommendations\Block\Placeholder\Related"
                       name="catalog.product.related.placeholder" as="related-products-placeholder"
                       template="N98_AjaxProductRecommendations::placeholder/related.phtml"
                       before="product.info.upsell">
                    <arguments>
                        <argument name="view_model" xsi:type="object">N98\AjaxProductRecommendations\ViewModel\RelatedViewModel</argument>
                    </arguments>
                </block>
            </referenceContainer>
            <!-- Remove related blocks as they are now loaded via AJAX -->
            <referenceBlock name="related_products_impression" remove="true"/>
            <referenceBlock name="catalog.product.related" remove="true"/>
        </body>
    </page>
    

    view/frontend/layout/n98_ajaxproductrecommendations_content_abstract.xml :

    <?xml version="1.0"?>
    <!--
    /**
     * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
     *
     * @see PROJECT_LICENSE.txt
     */
    -->
    <layout xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/layout_generic.xsd">
        <block class="Magento\TargetRule\Block\Catalog\Product\ProductList\Related" name="catalog.product.related" template="Magento_Catalog::product/list/items.phtml">
            <arguments>
                <argument name="type" xsi:type="string">related-rule</argument>
                <argument name="view_model" xsi:type="object">Magento\Catalog\ViewModel\Product\Listing\PreparePostData</argument>
            </arguments>
            <block class="Magento\Catalog\Block\Product\ProductList\Item\Container" name="related.product.addto" as="addto">
                <block class="Magento\Wishlist\Block\Catalog\Product\ProductList\Item\AddTo\Wishlist"
                       name="related.product.addto.wishlist" as="wishlist" before="compare"
                       template="Magento_Wishlist::catalog/product/list/addto/wishlist.phtml"/>
                <block class="Magento\Catalog\Block\Product\ProductList\Item\AddTo\Compare"
                       name="related.product.addto.compare" as="compare"
                       template="Magento_Catalog::product/list/addto/compare.phtml"/>
            </block>
        </block>
    </layout>
    

    view/frontend/templates/placeholder/related.phtml :

    <?php
    /**
     * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
     *
     * @see       PROJECT_LICENSE.txt
     */
    /** @var $block \N98\AjaxProductRecommendations\Block\Placeholder\Related */
    /** @var \N98\AjaxProductRecommendations\ViewModel\RelatedViewModel $viewModel */
    $viewModel = $block->getData('view_model');
    $product = $block->getProduct();
    ?>
    <?php if ($viewModel->productHasRelatedProducts($product)): ?>
        <?php
        $htmlPlaceholderId = 'product-recommendation-detailpage-related-placeholder';
        ?>
        <div id="<?php echo $htmlPlaceholderId; ?>"></div>
        <?php
        $ajaxUrl = $block->getAjaxUrl();
        $loaderImage = $block->escapeUrl($block->getViewFileUrl('images/loader-1.gif'));
        ?>
        <script type="text/x-magento-init">
        {
            "*": {
                "N98_AjaxProductRecommendations/js/related-products-loader": {
                    "ajaxUrl": "<?php echo $ajaxUrl; ?>",
                    "productId": "<?php echo $product->getId(); ?>",
                    "htmlPlaceholderId": "<?php echo $htmlPlaceholderId; ?>",
                    "loaderImage": "<?php echo $loaderImage; ?>"
                }
            }
        }
    
        </script>
    <?php endif; ?>
    

    view/frontend/web/js/related-products-loader.js :

    define([
        "jquery",
        "loader"
    ], function ($) {
        "use strict";
    
        function renderRelatedProducts(config) {
            var ajaxUrl = BASE_URL + config.ajaxUrl;
            var productId = config.productId;
            var elementId = config.htmlPlaceholderId;
            var loaderImageUrl = config.loaderImage;
    
            $(document).ready(function () {
                $.ajax({
                    context: '#' + elementId,
                    url: ajaxUrl,
                    type: 'POST',
                    data: {product_id: productId},
                    dataType: 'json',
                    beforeSend: function() {
                        $('#' + elementId).loader({icon: loaderImageUrl});
                        $('#' + elementId).trigger('processStart');
                    },
                    success: function(data) {
                        var element = $('#' + elementId);
                        if (data.success === true) {
                            element.html(data.output).trigger('contentUpdated');
                            $('form[data-role="tocart-form"]').catalogAddToCart();
                        } else {
                            element.html('<p><strong>' + data.output + '</strong></p>'); // display error message
                        }
                        return data.success;
                    },
                    complete: function() {
                        $('#' + elementId).trigger('processStop');
                    },
                    error: function() {
                        $('#' + elementId).trigger('processStop');
                    }
                });
            });
        }
    
        return renderRelatedProducts;
    });
    

    ViewModel/RelatedViewModel.php :

    <?php
    /**
     * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
     *
     * @see       PROJECT_LICENSE.txt
     */
    
    namespace N98\AjaxProductRecommendations\ViewModel;
    
    use Magento\Catalog\Api\Data\ProductInterface;
    use Magento\Catalog\Model\Product;
    use Magento\Framework\View\Element\Block\ArgumentInterface;
    
    /**
     * Class RelatedViewModel
     *
     * @package N98\AjaxProductRecommendations\ViewModel
     */
    class RelatedViewModel implements ArgumentInterface
    {
        /**
         * Product has related products check
         *
         * @param ProductInterface $product
         *
         * @return bool
         */
        public function productHasRelatedProducts(ProductInterface $product)
        {
            /** @var Product $product */
            $relatedCollection = $product->getRelatedLinkCollection();
    
            // use getSize() methods in order not to load collection but just trigger count sql
            if ($relatedCollection->getSize() > 0) {
                return true;
            }
    
            return false;
        }
    }
    

    Summing up

    The presented code was tested with Magento Commerce 2.3.5 and simple products only. The examples in this article do not claim to be complete. Feel free to leave a comment if you have a question or suggestion for improvements.

  • Request data through a proxy with GuzzleHttp – PSR-7 compliant

    Request data through a proxy with GuzzleHttp – PSR-7 compliant

    When a Magento shop – or any other PHP application – is required to request data from or transmit data to a remote server, it is often necessary to redirect the traffic through a Http-proxy server. In my case this was essential because the called endpoint allows only certain IP addresses to access the requested ressource. Since I am working from various locations, each having a different IP address, I had to find a way that certain Http-requests are routed through the company’s proxy server. This article is going to describe what a proxy actually does for us, why I cannot and should not use PHP’s environment variables and what the best solution is for scenarios where PSR-7 / PHP-Http in combination with GuzzleHttp might be.

    What about PSR-15?

    Good point! This text will discuss Http middleware components on client side with the example of adding proxy configurations to a request. The relatively new PSR-15 standard handles the server side and focuses mainly on manipulating the response which is sent back to the calling host. Nevertheless you will get a lot of useful information from the standard and the explanation.

    Why don’t you simply use the the HTTP_PROXY environment variable?

    Guzzle indeed accesses the PHP environment variable HTTP_PROXY and routes every request through the specified proxy, if this value is actually configured in php.ini or by using the setenv()-command. But for security reasons this feature is only available when the script is called from the shell (checked using method php_sapi_name()). More information on the vulnerability and the reason why GuzzleHttp declines to use the settings from environment variables can be found at httpoxy.org.

    GuzzleHttp offers a proxy-configuration within the request()-method – why not use this?

    Because it it not PSR-7 compliant! The HTTP message interface simply does not provide a method with name request that allows the developer to pass an options-array in which we could add our proxy-configuration. Instead the interface does only declare a method sendRequest that accepts an object of type \Psr\Http\Message\RequestInterface which also does not offer any proxy configuration.

    Middleware and Handlers

    Middleware components are a powerful – and PSR-7 compliant – way to manipulate Http requests before they’re sent to a remote server.

    In an example scenario where it would be required to send authentication data in each server call, it’d sure be exhausting to update the credentials for each method call all over your application’s code. Adding a middleware layer that automatically adds the necessary data to each request, before it is transmitted, will reduce the code lines to be updated to just a single one (maybe two).

    A middleware is simply a method that resides between the command for sending a request and the actual transmittion. It allows to add, remove or modify data, the target-URL or any other property of the \Psr\Http\Message\RequestInterface as well as additional configuration, that is important for data transfer.

    More information on the PSR-7 standard is given in our in-depth series.

    Create a middleware and add it to the HandlerStack

    The diagram below sketches on the top half the classical way of sending a request: The developer adds a a sendRequest command along with the connection and data settings hold by $request and passes it to a various transmittion mechanism, e.g. GuzzleHttp.

    In the bottom half of the diagram two sample middleware methods m1 and m2 are added to stack that resides between sending the request and the actual transmittion. Both methods are free to modify the request or the configuration hold by $options and are executed one after another, as described by the chain of responsibility, part of the infamous Gang of Four.

    The following implementation has proven to be stable but might be a bit more complex than what is actually necessary to create a middleware method. The reason for that is because of a better test-ability and more flexibility for future add-ons.

    Class ProxyMiddleware

    First step is to create a new class called ProxyMiddleware that is responsible to prepare the concrete middleware method for the handler stack, managed by GuzzleHttp. This class has static method, which holds the proxy settings (URL and port) and will return a closure that will be pushed onto the handler stack later.

    public static function getProxyMiddleware($serverUrl): callable
    {
        self::$proxyUrl = $serverUrl;
    ​
        return function (callable $handler) {
            return new ProxyMiddleware($handler);
        };
    }

    As soon as the closure is called during data transmittion a new instance of the ProxyMiddleware-class is created and invoked by the stack dispatcher. Therefore we need to implement the actual middleware-call inside of the magic __invoke-method:

    public function __invoke(RequestInterface $request, array $options)
    {
       $fn = $this->nextHandler;

       $options['proxy'] = [
           'http'  => self::$proxyUrl,
           'https' => self::$proxyUrl,
      ];

       return $fn($request, $options);
    }

    It’s that simple! We just add the proxy information to the $options-array and pass the new information onto the method described by $nextHandler. That was easy!

    Add our middleware to the handler stack

    After we have created the middleware handler, we need to tell GuzzleHttp to actually execute it after the sendRequest-command is executed. For this second step we need to instantiate a new HandlerStack-object using the class’ static create-method.

    $handlerStack = \GuzzleHttp\HandlerStack::create();
    $handlerStack->push(ProxyMiddleware::getProxyMiddleware('proxy_url'), 'middleware_id');

    $options['handler'] = $handlerStack;

    $httpClient = \Http\Adapter\Guzzle6\Client::createWithConfig($options);

    Now we simply push the previously described closure onto the stack together with an identifier-string. Allthough this ID is optional, it will be more than helpful for debugging your application, since Guzzle adds a couple of default middlewares to the stack and you would end up in a unmanageable mess.

    After we have created and configured the handler stack, we simply use the static createWithConfig-method that allows us to inject a options-array into the instantiation of a new Guzzle PSR-7 compliant Http client.

    From now on, all requests that are sent using this Http client will be transmitted with your middleware executed inbetween and having the proxy setting configured!

    Caveat: Unit Testing

    Testing your new middleware-class might be a little bit tricky. I just want you to know that closures, like the class member $nextHandler can be mocked using PHP’s pre-defined core class \stdClass and the magic __invoke method. In case you’re using PHPUnit, the following snippet might be helpful:

    $nextHandlerMock = $this->createPartialMock(\stdClass::class, ['__invoke']);

    More information

  • A visit at our friends of Atwix in Lviv/Ukraine

    A visit at our friends of Atwix in Lviv/Ukraine

    Last month we received an invitation by our friends of Atwix to attend their Barcamp in Lviv/Ukraine. My colleague Oleksandr and me were happy to join it. Oleksandr was the perfect mate, because he was grown up in Ukraine and so he knows everything about the traditions and local specialities.

    Our journey started in Frankfurt. After a stopover in Munich we arrived on site with enough energy to explore the beautiful city of Lviv.
    Oleksandr introduced me to the local delicacies like Borsch (Борщ), Blini (млинці) with Cherry and Varenyky (вареники).

    Traditional Ukraine Food

    After an delicious meal we met among others Slava Kravchuk (CEO of Atwix), Yaroslav, Maria Zayak, Tomislav Bilić (CEO of Inchoo) and Max Yekaterynenko (Director of Community Engineering at Adobe).

    Barcamp

    The Barcamp started with a breakfast where the attendees already had the chance to get to know each other.

    The agenda of the Barcamp was created in different way compared to previous Barcamps I attended. There were two parallel tracks with already defined talks and discussions. I had the honor to have a talk about Gitlab CI Build Pipelines.

    Agenda

    As you can see, there were a lot of interesting talks about all the hot Magento stuff like PWA. Also non Magento related topics like Remote Working were part of the agenda.

    After a long day with a lot of good content and discussions the day ended with a party at a very cool location where we tasted local food and drinks together with the Atwix and Inchoo guys.

    Magento Contribution Day

    Atwix as No. 1 Contributor had organized a Magento Contribution Day. A Contribution Day is a good chance to dive into the source code of the Magento Core.

    The procedure is very easy. Pick some topics from Magento Contribution backlog and try to fix, solve or invent stuff. If you do not know what you can do, visit the portal to start your way to contribute.

    https://opensource.magento.com/

    It is also possible to contribute to non code related topics like the developer documentation. I personally picked up an old bug ticket which was not edited since 2016.

    Conclusion

    We had a lot of good discussions with Atwix and Inchoo developers and project managers about PWA, Magento, Certification and a lot of more topics. I was able to share some insights about Gitlab-CI pipelines which shows the netz98 way of building projects.
    The evening event was great. The Contribution Day, too…

    Thank you for the hospitality. See you next time in Lviv.

  • How a wrong carrier implementation causes a server outage

    How a wrong carrier implementation causes a server outage

    Sometimes one wrong line of code can break your site. In the following I will describe a mistake in a Magento 2 custom carrier implementation, which causes a massive overloading of server resources (CPU, RAM, DB processes) and even can cause an outage of your Magento store.

    The one line of code

    The following line of code is the reason for the problems, if used in the collectRates() method, or in methods, called from collectRates() in the Carrier class:

    $quote = $this->checkoutSession->getQuote();
    

    So, in other words, you must not obtain the quote object globally via the checkout session.

    The reason

    The method \Magento\Checkout\Model\Session::getQuote(), called for the first time, triggers loading the quote. If we then look at the method \Magento\Quote\Model\Quote::_afterLoad() :

        /**
         * Trigger collect totals after loading, if required
         *
         * @return $this
         */
        protected function _afterLoad()
        {
            // collect totals and save me, if required
            if (1 == $this->getTriggerRecollect()) {
                $this->collectTotals()->save();
                $this->setTriggerRecollect(0);
            }
            return parent::_afterLoad();
        }

    We then can see, that for quotes, having the field (also a DB column) trigger_recollect set to 1, collectTotals() method is called.

    An attentive reader will already notice, what is going wrong here. It’s an infinite loop! Quote::collectTotals() will trigger shipping carriers’ method collectRates() and thats where the loop is closed.

    The trigger_recollect flag is set in Magento:

    • for quotes depending on catalog price rules
    • for quotes containing products which were updated (e.g. in Admin or via API)

    In my case there were a lot of such kind of quotes because of frequent product updates.

    The result was overloaded CPUs, RAM, full MySQL process list and several outages as the infinite loops were being executed for the value of seconds equals PHP max_execution_time.

    How to avoid this

    The shipping carrier’s method collectRates() gets the object of the class \Magento\Quote\Model\Quote\Address\RateRequest passed, where the already loaded quote object should be obtained from (if needed). Unfortunately there is no method “getQuote()” in the RateRequest class. The following snippet shows an example of obtaining the quote correctly:

            /**
             * Do not use checkoutSession->getQuote()!!! it will cause infinite loop for
             * quotes with trigger_recollect = 1, see Quote::_afterLoad()
             */
            $items = $request->getAllItems();
            if (empty($items)) {
                return false;
            }
    
            /** @var \Magento\Quote\Model\Quote\Item $firstItem */
            $firstItem = reset($items);
            if (!$firstItem) {
                return false;
            }
    
            $quote = $firstItem->getQuote();
            if (!($quote instanceof \Magento\Quote\Model\Quote)) {
                return false;
            }

    I hope this post can save some nerves for you and your team. Feel free to leave a comment.

  • How to avoid security issues in Composer dependencies

    Composer is a great tool for requiring third party modules and software packages for your project. It’s an essential part of the current Magento 2 project structure.

    Because of the possibility to add more and more modules it is also getting more and more difficult to keep track of relevant security updates. That is especially the case when required modules have further requirements.

    Here are 3 tips how to improve your project’s security

    1. Subscription of third party repositories (when using sticky version numbers)

    If the applied module is published on Github you can subscribe to the repository. Github then informs you via email about changes of the code. If the email contains relevant information regarding security issues, the version of the required module can be increased in your project‘s composer.json file. For updating you just have to type:

    composer update vendor/module

    Subscribing @ Github

    2. Avoid static version numbers

    Compared to option 1 it would be much more safe to manage the applied parts and the own project via semantic versioning. In those cases you can define the dependencies as follows:

    composer require vendor/module=~3.1

    In this specific case you would get all versions between 3.1.0 and 3.2.0 by executing a “composer update”. If the vendor fixes the module, he just increases the version number at the third position and the new code will be applied and implemented with the next update.

    If the vendor implements a new feature however, he increases the major or minor version, e.g. 3.2.x or 4.0.x. The code will only be applied by a manual action of a developer. Reviewing the code before committing the new version would be highly recommended.

    3. Use SensioLabs Scanner

    While option 2 is already a very cool and flexible solution, the developers of the vendors of the applied modules still need to be informed about the security issues and they need to fix them. But the moment they release a new version might be too late. As a developer, you definitely want to know about issues immediately, to disable or replace the dangerous module.

    SensioLabs – creator of the Symfony framework – created a database containing known security issues of several packages (Magento, Shopware, Zend Framework etc.) and they allow you to get these information via an api or a cli client. These tools scan a given composer.lock file. For every current commit or tag they check for an entry inside the database. After processing the file you get a list of findings if there were any.

    Security Check: Success

    Security Check: Error

    The client can be easily installed via Composer.

    composer require sensiolabs/security-checker

    Depending on your project and your required Symfony dependencies you might need to use a specific version of the tool.

    To run it, all you have to type is:

    vendor/bin/security-checker security:check

    By today there’s only one entry for Magento 2 in this database:

     

    Links