Hlavní navigace

Úvod do frameworku Symfony: flashové správy

Ján Bodnár

V šiestom pokračovaní série článkov o Symfony si spojíme viaceré koncepty, o ktorých sme hovorili v minulých častiach a pridáme flashové správy. Ukážeme si tiež, ako pomocou relácie uchováme vyplnené políčka formulára.

Doba čtení: 9 minut

Sdílet

V našej aplikácii budeme mať formulár s dvoma políčkami: meno užívateľa a email. Vstup od užívateľa bude validovaný pomocou Symfony validátora. Formulár je typu POST, preto budeme používať CSRF ochranu. V prípade nesprávne vyplnených údajov budeme presmerovaný späť na formulár, kde sa nám zobrazia chybové hlášky vo forme krátkodobých flashových správ. Vyplnené údaje budeme uchovávať pomocou relácie a po presmerovaní sa nestratia.

Flash bag

Flashové správy sú krátkodobé notifikácie, ktoré sú ukladané do špeciálneho miesta v relácii, ktoré sa nazýva flash bag. Tieto hlášky sú hneď vymazané potom, ako sú zobrazené užívateľovi. Flashové správy sú typicky používané v kombinácii s presmerovaním.

Reláciu získame z requestu metódou getSession(). Z relácie získame flash bag metódou getFlashBag(). V Twig šablóne sa na flashové úložisko odkazujeme využitím app.flashes.

Príprava

Najprv si composerom nainštalujeme potrebné komponenty.

$ composer create-project symfony/skeleton symflash
$ cd symflash

Vytvoríme nový Symfony projekt a presunieme sa do pracovného adresára.

$ composer req twig annot validator

Nainštalujeme Twig šablónovací systém, podporu anotácií a Symfony validator komponent. Pripomínam, že komponenty môžu mať viaceré aliasy. Napríklad symfony/validator má aliasy validation a validator. Podrobnosti nájdeme na stránke Symfony Recipes Server.

$ composer req symfony/security-csrf
$ composer req symfony/monolog-bundle

Ďalej si stiahneme balík pre CSRF ochranu a Monolog pre logovanie.

$ composer require server maker --dev

Nainštalujeme developerský server a maker komponent.

Aplikácia

Začneme s tvorbou controllerov.

$ php bin/console make:controller HomeController

Vytvoríme si controller s názvom HomeController.

<?php

// src/Controller/HomeController.php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;

class HomeController extends AbstractController
{
    /**
     * @Route("/home", name="home")
     */
    public function index()
    {
        return $this->render('home/index.html.twig');
    }
}

HomeController je jednoduchý controller, ktorý nám pošle formulár.

$ php bin/console make:controller UserController

Ďalej si vytvoríme UserController, ktorý reaguje na odoslanie formulára.

<?php

// src/Controller/UserController.php

namespace App\Controller;

use App\Service\ValidationService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class UserController extends AbstractController
{
    /**
     * @Route("/create-user", name="create-user")
     */
    public function index(Request $request, ValidationService $validator)
    {
        $token = $request->get("token");

        $valid = $validator->validateToken($token);

        if (!$valid) {

            return new Response("Operation not allowed", Response::HTTP_BAD_REQUEST,
                ['content-type' => 'text/plain']);
        }

        $name = $request->request->get("name");
        $email = $request->request->get("email");

        $input = ['name' => $name, 'email' => $email];

        $errorMessages = $validator->validateInput($input);

        if (count($errorMessages) > 0)
        {
            $session = $request->getSession();
            $session->set('name', $name);
            $session->set('email', $email);

            $session->getFlashBag()->setAll($errorMessages);

            return $this->redirectToRoute('home');

        } else {

            return new Response("User saved", Response::HTTP_OK,
                ['content-type' => 'text/plain']);
        }
    }
}

V UserControlleri  overíme CSRF token, validujeme dáta z formulára a pošleme odpoveď späť klientovi.

public function index(Request $request, ValidationService $validator)
{

Validácia je delegovaná do servisnej triedy ValidationService. Symfony je o.i. Dependency Injection (DI) kontajner, ktorý nám automaticky vytvára potrebné objekty. (Tie sa v tomto kontexte nazývajú závislosti – dependencies). V našom prípade sa pre nás vytvoria objekty Request a ValidationService.

$token = $request->get("token");

$valid = $validator->validateToken($token);

if (!$valid) {

    return new Response("Operation not allowed", Response::HTTP_BAD_REQUEST,
        ['content-type' => 'text/plain']);
}

Najprv sa verifikuje CSRF token. V prípade, že token nie je validný, pošle sa príslušná chybová hláška. O CSRF ochrane sme podrobnejšie hovorili v štvrtej časti.

$name = $request->request->get("name");
$email = $request->request->get("email");

$input = ['name' => $name, 'email' => $email];

$errorMessages = $validator->validateInput($input);

Z request objektu získame dáta, ktoré sme tam zapísali pomocou formulára. Dáta predáme validátoru na spracovanie. Ak došlo pri validácii ku chybám, získame chybové hlášky.

if (count($errorMessages) > 0)
{
    $session = $request->getSession();
    $session->set('name', $name);
    $session->set('email', $email);
...

Ak nám validátor vrátil nejaké chybové hlášky, uložíme input z formulára do relácie (session), aby sme ich neskôr mohli použiť po presmerovaní. Prístup do relácie získame pomocou metódy getSession(). Užívateľa rozhodne nepoteší, ak pri chybe musí vypĺňať formulár znovu. Preto sa vstup od užívateľa zvykne ukladať na určitú dobu do relácie a po presmerovaní naspäť na formulár sú údaje predvyplnené.

$session->getFlashBag()->setAll($errorMessages);

Ďalej sa v prípade výskytu chýb uložia chybové hlášky do úložiska flashových správ (flash bag).

return $this->redirectToRoute('home');

Nakoniec v prípade chyby dôjde k presmerovaniu na východzí formulár.

...
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
    resource: '../src/*'
    exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'
...

Validáciu delegujeme do servisnej triedy. Ak v koštruktore alebo metóde použijeme potrebný typehint, Symfony kontajner nám prislúchajúcu triedu vygeneruje. V konfiguračnom súbore config/services.yaml nájdeme adresáre, z ktorých môžeme využiť služby Symfony DI.

<?php

// src/Service/ValidationService.php

namespace App\Service;

use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;

class ValidationService
{
    private $tokenManager;
    private $validator;
    private $accessor;
    private $logger;

    public function __construct(CsrfTokenManagerInterface $tokenManager,
        ValidatorInterface $validator)
    {
        $this->tokenManager = $tokenManager;
        $this->validator = $validator;
        $this->logger = $logger;
    }

    public function validateToken($token): bool
    {
        $csrf_token = new CsrfToken('myform', $token);

        $isValid = $this->tokenManager->isTokenValid($csrf_token);

        if (!$isValid) {
            $this->logger->error("CSRF failure");
        }

        return $isValid;
    }

    public function validateInput(array $input): array
    {
        $constraints = new Assert\Collection([
            'name' => [new Assert\Length(['min' => 2]), new Assert\Regex('/^[a-zA-Z1-9]+$/'),
                        new Assert\NotBlank],
            'email' => [new Assert\Email, new Assert\NotBlank],
        ]);

        $violations = $this->validator->validate($input, $constraints);

        if (count($violations) > 0) {

            $this->logger->info("Validation failed");

            $messages = [];

            foreach ($violations as $violation) {

                $field = substr($violation->getPropertyPath(), 1, -1);
                $messages[] = [$field => $violation->getMessage()];
            }

            $output = [
                'name' => array_unique(array_column($messages, 'name')),
                'email' => array_unique(array_column($messages, 'email')),
            ];

             return $output;
        } else {

            return [];
        }
    }
}

Trieda ValidationService overuje CSRF kód a validuje formulárový vstup.

public function __construct(CsrfTokenManagerInterface $tokenManager,
    ValidatorInterface $validator, LoggerInterface $logger)
{
    $this->tokenManager = $tokenManager;
    $this->validator = $validator;
    $this->logger = $logger;
}

Necháme Symfony kontajnerom vygenerovať tri objekty: token manager, validator a logger.

public function validateToken($token): bool
{
    $csrf_token = new CsrfToken('myform', $token);

    $isValid = $this->tokenManager->isTokenValid($csrf_token);

    if (!$isValid) {
        $this->logger->error("CSRF failure");
    }

    return $isValid;
}

Token, ktorý sme získali z formulára validujeme token managerom pomocou metódy isTokenValid(). V prípade zlyhania validácie zapíšeme chybovú hlášku do logu na var/log/dev.log.

$constraints = new Assert\Collection([
    'name' => [new Assert\Length(['min' => 2]), new Assert\Regex('/^[a-zA-Z1-9]+$/'),
                new Assert\NotBlank],
    'email' => [new Assert\Email, new Assert\NotBlank],
]);

Pre náš formulár máme tieto pravidlá. Meno a email nesmú byť prázdne. Meno musí mať aspoň dva znaky a obsahovať len alfanumerické znaky a email musí byť platná emailová adresa.

$violations = $this->validator->validate($input, $constraints);

Pomocou validátora validujeme input použitím zadefinovaných pravidiel.

if (count($violations) > 0) {

    $this->logger->info("Validation failed");

    $messages = [];

    foreach ($violations as $violation) {

        $field = substr($violation->getPropertyPath(), 1, -1);
        $messages[] = [$field => $violation->getMessage()];
    }

    $output = [
        'name' => array_unique(array_column($messages, 'name')),
        'email' => array_unique(array_column($messages, 'email')),
    ];

        return $output;
} else {

    return [];
}

Ak zoznam porušení pravidiel (violations) nie je prázdny, vytvoríme chybové hlášky. Chybové hlášky zoskupíme podľa názvov validovaných políčiek. Ak nedošlo k porušeniam, vrátime prázdne pole.

{# templates/home/index.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Home page{% endblock %}

{% block stylesheets %}
<style>
    .topmargin {
        margin-top: 1em;
    }
</style>
{% endblock %}

{% block body %}

<section class="ui container topmargin">

    <form class="ui form" action="{{path('create-user')}}" method="post">

        <input type="hidden" name="token" value="{{ csrf_token('myform') }}" />

        {% for msg in app.flashes('name') %}
        <div class="ui small red message">
            {{ msg }}
        </div>
        {% endfor %}

        <div class="field">
            <label>Name:</label>
            <input type="text" name="name" value="{{app.session.get('name')}}">
        </div>

        {% for msg in app.flashes('email') %}
        <div class="ui small red message">
            {{ msg }}
        </div>
        {% endfor %}

        <div class="field">
            <label>Email</label>
            <input type="text" name="email" , value="{{app.session.get('email')}}">
        </div>

        <button class="ui button" type="submit">Send</button>

    </form>

</section>

{% endblock %}

Toto je domovská stránka s formulárom. Formulár obsahuje dve polia: meno užívateľa a email.

<form class="ui form" action="{{path('create-user')}}" method="post">

Do atribútu action zadáme názov cesty (route). Použijeme Twig path() funkciu, ktorá vráti relatívnu URL zodpovedajúcu danému názvu cesty. Pomocou takéhoto nepriameho odkazu dosiahneme určitú flexibilitu. Môžeme ľubovoľne meniť názvy URL reťazcov bez toho, aby sme ich museli všade upravovať.

<input type="hidden" name="token" value="{{ csrf_token('myform') }}" />

Tento skrytý input tag nám vygeneruje CSRF token pomocou Twig funkcie csrf_token().

{% for msg in app.flashes('name') %}
<div class="ui small red message">
    {{ msg }}
</div>
{% endfor %}

Ak máme vo flash bagu pre atribút name chybové hlášky, tak ich zobrazíme. Keďže validačných pravidiel môže byť viac, používame for direktívu.

<input type="text" name="name" value="{{app.session.get('name')}}">

Atribút value nám umožňuje zadať východzie hodnoty pre input tag. Ak sa v relácii nachádza hodnota name, tak ju použijeme. Hodnoty získavame pomocou app.session.get(). Toto sa využíva po presmerovaní späť na formulár, aby užívateľ nemusel už zadané hodnoty znova zapisovať.

{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        <link href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css"
                    rel="stylesheet">
        {% block stylesheets %}{% endblock %}
    </head>
    <body>
        {% block body %}{% endblock %}

        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.3.1/semantic.min.js"></script>
        {% block javascripts %}{% endblock %}
    </body>
</html>

Toto je bázová Twig šablóna. Obsahuje deklarácie pre Semantic UI knižnicu, pomocou ktorej tvoríme vizuál formulára.

$ php bin/console server:run

Spustíme vývojársky server a do prehliadača zadáme localhost:8080/home.


Formulár s flash správou

Ak stránku refreshneme, tak sa nám flashová správa vytratí.

V tejto časti seriálu sme sa podrobnejšie zaoberali flash správami a reláciou. Nabudúce si predstavíme čerstvú novinku: Symfony HttpClient komponent.