Welcome to Abdul Malik Ikhsan's Blog

Create Middleware for File Post/Redirect/Get in Expressive 3

Posted in expressive, Teknologi, Tutorial PHP, Zend Framework by samsonasik on July 31, 2018

Previously, I wrote a post about Post/Redirect/Get in Expressive 3 which specifically handle POST parsed body. How about File upload? Let’s do it!

In this post, I will do with Zend\Form component.

Let’s start by install a fresh skeleton:

$ composer create-project \
     zendframework/zend-expressive-skeleton \
     expressive-fileprg-tutorial

To save temporary the form data and its invalid input error messages during redirect, we can use session, so, we can require session components:

$ cd expressive-fileprg-tutorial
$ composer require \
     zendframework/zend-expressive-session:^1.0.0 \
     zendframework/zend-expressive-session-ext:^1.1.1

After above components installed, ensure that your config/config.php injected with ConfigProvider like below:

<?php
// config/config.php
$aggregator = new ConfigAggregator([
    // session requirements
    \Zend\Expressive\Session\Ext\ConfigProvider::class,
    \Zend\Expressive\Session\ConfigProvider::class,
    // ...
]);

The session middleware from zend-expressive-session component need to be registered in our config/pipeline.php before RouteMiddleware:

// config/pipeline.php
// ...
use Zend\Expressive\Session\SessionMiddleware;

// ...
$app->pipe(SessionMiddleware::class);
$app->pipe(RouteMiddleware::class);

Next, we are going to require a form dependencies for it with a command:

$ composer require \
     zendframework/zend-form:^2.11 \
     zendframework/zend-i18n:^2.7

After above components installed, ensure that your config/config.php have sessions and form ConfigProviders:

<?php
// config/config.php
$aggregator = new ConfigAggregator([
    // session requirements
    \Zend\Expressive\Session\Ext\ConfigProvider::class,
    \Zend\Expressive\Session\ConfigProvider::class,
    // ...

    // form requirements
    \Zend\I18n\ConfigProvider::class,
    \Zend\Form\ConfigProvider::class,
    \Zend\InputFilter\ConfigProvider::class,
    \Zend\Filter\ConfigProvider::class,
    \Zend\Hydrator\ConfigProvider::class,
]);

The New Middleware

We are going to create a middleware for our application, for example, we name it FilePrgMiddleware, placed at src/App/Middleware. I will explain part by part.

Unlike the normal PRG middleware in previous post, the FilePrgMiddleware need to bring the Zend\Form\Form object to be filtered and validated, so, it will be applied in the routes after the Page handler registration.

First, we check whether the request method is POST and has the media type is “multipart/form-data”, then applied POST and FILES data into the form instance. We can save form post and file data, a form filtered/validated data (“form->getData()”), and its form error messages into session. Next, we redirect to current page with status code = 303.

As I explored, the easiest way to work with zend-form for post and files data is by using zend-psr7bridge for it like below:

use Zend\Diactoros\Response\RedirectResponse;
use Zend\Expressive\Session\SessionMiddleware;
use Zend\Psr7Bridge\Psr7ServerRequest;

$session     = $request->getAttribute(SessionMiddleware::SESSION_ATTRIBUTE);

$zendRequest        = Psr7ServerRequest::toZend($request);
$zendRequestHeaders = $zendRequest->getHeaders();
$isMultiPart        = $zendRequestHeaders->has('Content-type')
    ? $zendRequestHeaders->get('Content-type')->getMediaType() === 'multipart/form-data'
    : false;

if ($request->getMethod() === 'POST' && $isMultiPart === true) {
    $postAndFileData = \array_merge_recursive(
        $zendRequest->getPost()->toArray(),
        $zendRequest->getFiles()->toArray()
    );

    $session->set('post_and_file_data', $postAndFileData);
    $form->setData($postAndFileData);

    if ($form->isValid()) {
        $session->set('form_data', $form->getData());
    }

    if ($messages = $form->getMessages()) {
        $session->set('form_errors', $messages);
    }

    return new RedirectResponse($request->getUri(), 303);
}

On next flow, we can check if the session has “post_and_file_data” key, set form error messages when session has “form_errors” key, and return Response.

if ($session->has('post_and_file_data')) {
    $form->setData($session->get('post_and_file_data'));
    $session->unset('post_and_file_data');

    if ($session->has('form_errors')) {
        $form->setMessages($session->get('form_errors'));
        $session->unset('form_errors');
    }

    return new Response();
}

Above, we didn’t use the “form_data” session as the form data may be used in the page handler, so, we can handle it with keep the “form_data” as “form->getData()” result once by return Response above, and remove it when it already hit in next refresh, so next flow can be:

if ($session->has('form_data')) {
    $session->unset('form_data');
}

return new Response();

The complete middleware class can be as follow:

<?php
// src/App/Middleware/FilePrgMiddleware.php
declare(strict_types=1);

namespace App\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\Response\RedirectResponse;
use Zend\Expressive\Session\SessionMiddleware;
use Zend\Psr7Bridge\Psr7ServerRequest;

class FilePrgMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ) : ResponseInterface
    {
        $session     = $request->getAttribute(SessionMiddleware::SESSION_ATTRIBUTE);

        $zendRequest        = Psr7ServerRequest::toZend($request);
        $zendRequestHeaders = $zendRequest->getHeaders();
        $isMultiPart        = $zendRequestHeaders->has('Content-type')
            ? $zendRequestHeaders->get('Content-type')->getMediaType() === 'multipart/form-data'
            : false;

        $form = $request->getAttribute('form');
        if ($request->getMethod() === 'POST' && $isMultiPart === true) {
            $postAndFileData = \array_merge_recursive(
                $zendRequest->getPost()->toArray(),
                $zendRequest->getFiles()->toArray()
            );

            $session->set('post_and_file_data', $postAndFileData);
            $form->setData($postAndFileData);

            if ($form->isValid()) {
                $session->set('form_data', $form->getData());
            }

            if ($messages = $form->getMessages()) {
                $session->set('form_errors', $messages);
            }

            return new RedirectResponse($request->getUri(), 303);
        }

        if ($session->has('post_and_file_data')) {
            $form->setData($session->get('post_and_file_data'));
            $session->unset('post_and_file_data');

            if ($session->has('form_errors')) {
                $form->setMessages($session->get('form_errors'));
                $session->unset('form_errors');
            }

            return new Response();
        }

        if ($session->has('form_data')) {
            $session->unset('form_data');
        }

        return new Response();
    }
}

FilePrgMiddleware Service Registration

We can register the FilePrgMiddleware at src/App/ConfigProvider under getDependencies() function:

// src/App/ConfigProvider.php
use Zend\ServiceManager\Factory\InvokableFactory;

    // ...
    public function getDependencies() : array
    {
        return [
            'factories'  => [
                // ...
                Middleware\FilePrgMiddleware::class => InvokableFactory::class,
            ],
        ];
    }

The Upload Form and Its Page

Time for usage. First, we can create an upload form, for example like below:

<?php

namespace App\Form;

use Zend\Form\Element\File;
use Zend\Form\Element\Submit;
use Zend\Form\Form;
use Zend\InputFilter\InputFilterProviderInterface;
use Zend\Validator\File\MimeType;
use Zend\Validator\File\Size;
use Zend\Filter\File\RenameUpload;

class UploadForm extends Form implements InputFilterProviderInterface
{
    public function __construct()
    {
        parent::__construct('upload-form');

        $this->init();
    }

    public function init()
    {
        $this->add([
            'type' => File::class,
            'name' => 'filename',
            'options' => [
                'label' => 'File upload',
            ],
        ]);

        $this->add([
            'name' => 'submit',
            'type' => Submit::class,
            'attributes' => [
                'value' => 'Submit',
            ],
        ]);
    }

    public function getInputFilterSpecification()
    {
        return [
            [
                'name' => 'filename',
                'required' => true,
                'filters' => [
                    [
                        'name' => RenameUpload::class,
                        'options' => [
                            'target'               => \getcwd() . '/public/uploads',
                            'use_upload_extension' => true,
                            'use_upload_name'      => true,
                            'overwrite'            => true,
                            'randomize'            => false,
                        ],
                    ],
                ],
                'validators' => [
                    [
                        'name' => Size::class,
                        'options' => [
                            'max' => '10MB',
                        ],
                    ],
                    [
                        'name' => MimeType::class,
                        'options' => [
                            'mimeType' => [
                                'image/jpg',
                                'image/jpeg',
                                'image/png',
                            ],
                        ]
                     ]
                ],
            ],
        ];
    }
}

To ensure the file upload correctly, I applied filter RenameUpload to move it to public/uploads directory. We can create an uploads directory by run command:

$ mkdir -p public/uploads && chmod 755 public/uploads

Next, we can create an Upload Page Handler :

<?php

declare(strict_types=1);

namespace App\Handler;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response\HtmlResponse;
use Zend\Expressive\Template;

class UploadPageHandler  implements MiddlewareInterface
{
    private $template;

    public function __construct(Template\TemplateRendererInterface $template)
    {
        $this->template      = $template;
    }

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ) : ResponseInterface
    {
        $data = [];
        return new HtmlResponse($this->template->render('app::upload-page', $data));
    }
}

We need template in templates/app/upload-page.phtml for it. For view, we can create a file:

$ touch templates/app/upload-page.phtml

and write with the form helper:

<?php // templates/app/upload-page.phtml

echo $this->form($form);

As above UploadPageHandler require an TemplateRendererInterface service, we need factory for it, as follow:

<?php

declare(strict_types=1);

namespace App\Handler;

use Psr\Container\ContainerInterface;
use Psr\Http\Server\MiddlewareInterface;
use Zend\Expressive\Template\TemplateRendererInterface;

class UploadPageHandlerFactory
{
    public function __invoke(ContainerInterface $container) : MiddlewareInterface
    {
        $template = $container->get(TemplateRendererInterface::class);
        return new UploadPageHandler($template);
    }
}

We can register the UploadPageHandler at src/App/ConfigProvider under getDependencies() function:

// src/App/ConfigProvider.php
use Zend\ServiceManager\Factory\InvokableFactory;

    // ...
    public function getDependencies() : array
    {
        return [
            'factories'  => [
                // ...
                Handler\UploadPageHandler::class => Handler\UploadPageHandlerFactory::class,
                // ...
            ],
        ];
    }

To make it accessible, we need to register its routing, for example, at config/routes.php:

return function (Application $app, MiddlewareFactory $factory, ContainerInterface $container) : void {
    // ...
    $app->route(
        '/upload',
        [
            App\Handler\UploadPageHandler::class,
            App\Middleware\FilePrgMiddleware::class,
        ],
        [
            'GET',
            'POST'
        ],
        'upload'
    );
    // ...
};

So, we will get the following display when access “/upload” page:

Using The FilePrg in UploadPageHandler

We can set the Zend\Form\Form instance into request attribute and call it handler, on form is valid, we can apply after its response check not a RedirectResponse.

use App\Form\UploadForm;
use Zend\Expressive\Session\SessionMiddleware;

// ...
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ) : ResponseInterface
    {
        $form = new UploadForm();

        $request = $request->withAttribute('form', $form);
        $response = $handler->handle($request);
        if ($response instanceof RedirectResponse) {
            return $response;
        }

        $session     = $request->getAttribute(SessionMiddleware::SESSION_ATTRIBUTE);
        if ($session->has('form_data')) {
            $formData = $session->get('form_data');
            // we can returns RedirectResponse to "success upload" page,
            // process form data,
            // set view variable to allow display that the upload success,
            // etc
        }

        $data = ['form' => $form];
        return new HtmlResponse($this->template->render('app::upload-page', $data));
    }
// ...

When we get invalid input, we will get the following form page like below:

When we refresh it, it will normal refresh and won’t call a duplicate form submission.

That’s it ;).

Tagged with: ,