Filtrowanie i walidacja danych w PHP z wykorzystaniem komponentów Laminasa

Autor:  Krystian Żądło
08-04-2021

Walidacja danych to rzecz niezwykle istotna w procesie tworzenia oprogramowania w PHP -ie. Chcąc utrzymać właściwą kontrolę nad aplikacją, w większości z nich trzeba będzie zmierzyć się z danymi pochodzącymi z zewnętrznego środowiska. Czy to pobieranymi z innych systemów, czy po prostu wprowadzanymi przez użytkowników oprogramowania. Walidacja danych jest więc czynnością, która ma na celu sprawdzić ich poprawność.

Ludzie często bazują na zaufaniu, ale systemy informatyczne nie powinny. Nie warto zakładać, że wszystkie otrzymywane dane będą poprawne. Powiem więcej, lepiej od razu założyć, że nie będą. Mimo, że w wielu przypadkach wydawać by się mogło, że aplikacja nie wymaga walidacji. No przecież jest wykorzystywana wewnętrznie, a korzystają z niej tylko zaufane osoby… Istnieje jednak wiele ataków polegających na wykorzystaniu osób, które mają odpowiednie uprawnienia. No dobra, ale pomijając już oczywisty aspekt bezpieczeństwa, sam użytkownik może przez przypadek wprowadzić nieprawidłowe dane, których przetworzenie prowadzić będzie do błędów w oprogramowaniu.

Myślę, że takie krótkie wprowadzenie okaże się wystarczające. To tylko niektóre z istotnych argumentów, dlaczego walidacja jest procesem obowiązkowym. Zakładam jednak optymistycznie, że nie ma potrzeby wnikliwego tłumaczenia dlaczego. Zamiast tego skupmy się na tym jak to zrobić. Dodam jeszcze tylko, że sprawdzać dane można zarówno po stronie klienta jak i serwera. Ta pierwsza jest opcjonalna, ale może polepszyć funkcjonalność danego projektu. Ta druga jest obligatoryjna. I to ona będzie bohaterem tego artykułu.

Instalacja bibliotek Laminasa

Walidacja danych jest tak podstawową kwestią, że w każdej technologii istnieje masa gotowych narzędzi, które pozwalają na jej realizację. I dobrze, bo zazwyczaj nie ma sensu pisać własnej obsługi. Każdy ceniony framework dostarcza gotowy komponent. Niektóre są bardziej zależne od środowiska, inne z powodzeniem można wykorzystywać w każdej aplikacji.

W tym przykładzie do filtrowania i walidacji danych wykorzystane zostaną uniwersalne komponenty ze stajni Laminasa. Biblioteki oczywiście najlepiej współgrają z całą gammą pozostałych spod tego szyldu, ale nie ma problemu żeby z części z nich korzystać w każdej innej aplikacji opartej o PHP. Nawet jeśli są to technologie jak Symfony, czy Laravel. Oczywiście te również dostarczają swoje komponenty do walidacji danych, ale może komuś bardziej przypadną do gustu komponenty do filtrowania i walidacji danych od Laminas. I tak laminas-validator oraz laminas-filter mają minimalne zależności, więc łatwo je wpiąć do aplikacji, ale już laminas-inputfilter wymaga kilku dodatkowych, więc najprędzej używa się go właśnie w aplikacjach opartych o Laminas MVC czy o Mezzio.

Zakładam, że w Waszych projektach korzystacie z narzędzia Composer. Właśnie przy jego użyciu bardzo łatwo zainstalować wyżej wspomniane zależności, wykonując pojedyncze polecenie:

composer require laminas/laminas-inputfilter

W tym momencie do dyspozycji są wszystkie trzy, bo ta wymiona w procesie instalacji ma w
zależnościach dwie kolejne, więc automatycznie je dociągnie.

Filtrowanie i walidacja danych w PHP

W przykładowym kodzie zaprezentowana zostanie walidacja danych pochodzących z warstwy HTTP. Nieistotny jest sposób ich dostarczenia. Mogą pochodzić z innej aplikacji, API czy formularzy. Po ich pobraniu z metody POST, przekazane zostaną do odpowiedniej klasy, która najpierw je przefiltruje, a następnie sprawdzi ich zgodność z regułami dostarczonymi w walidatorze. Jeśli dane są niepoprawne to zwróci odpowiedź z komunikatem błędu. Jeśli są właściwe, pewnie dalej coś się z nimi zadzieje. Ten fragment kodu zostanie pominięty, żeby nie zaciemniać. Materiał skupia się na samym procesie walidowania.

class ArticleController
{
    private ArticleInputFilter $articleInputFilter;

    public function __construct(ArticleInputFilter $articleInputFilter)
    {
        $this->articleInputFilter = $articleInputFilter;
    }

    public function createAction()
    {
        // fetch data from HTTP POST

        $this->articleInputFilter->setData($dataFromRequest);

        if (!$this->articleInputFilter->isValid()) {
            $errorMessages = $this->articleInputFilter->getMessages();

            // return response with error messages
        }

        $validData = $this->articleInputFilter->getValues();

        // do something with article valid data
    }
}

Powyższy kod prezentuje przykładową akcję umożliwiającą stworzenie artykułu. Oczywiście skupia się tylko na walidacji danych przesyłanych do systemu. Doszukać się można tutaj przykładowego wykorzystania obiektu InputFilter, który jest dedykowanym zbiorem filtrów i walidatorów dla pól artykułu. Jak widzicie, nie ma w tym nic trudnego. Najpierw trzeba przekazać dane za pomocą metody setData(), później sprawdzić czy są poprawne używając isValid(). Jeśli nie są, można pobrać treści błędów wygenerowanych przez walidatory dzięki getMessages() (domyślnie w języku angielskim, ale można doinstalować tłumaczenia). Jeśli są, przefiltrowane i zwalidowane dane pobierane są metodą getValues(). I to wszystko – łatwo i przyjemnie jeśli chodzi o samą obsługę, czyli kod kliencki.

Teraz ważniejsza część z perspektywy tego wpisu, czyli stworzenie klasy o którą opiera się cały powyższy mechanizm. Gotowych filtrów i walidatorów jest naprawdę sporo, ale starałem się dobrać taki przykład, by pokazać ich jak najwięcej. Ich pełny spis można rzecz jasna znaleźć w dokumentacji.

<?php

declare(strict_types=1);

namespace Article;

use Laminas\Filter\File\RenameUpload;
use Laminas\Filter\ToInt;
use Laminas\Validator\Date;
use Laminas\Validator\EmailAddress;
use Laminas\Validator\File\Extension;
use Laminas\Validator\File\FilesSize;
use Laminas\Validator\File\MimeType;
use Laminas\Validator\File\UploadFile;
use Laminas\Validator\GreaterThan;
use Laminas\Validator\Hostname;
use Laminas\Filter\StringTrim;
use Laminas\Filter\StripNewlines;
use Laminas\Filter\StripTags;
use Laminas\Filter\ToNull;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator\Digits;
use Laminas\Validator\LessThan;
use Laminas\Validator\StringLength;

class ArticleInputFilter extends InputFilter
{
    private const TITLE = 'title';
    private const CONTENT = 'content';
    private const CREATION_DATE = 'creationDate';
    private const AUTHOR_EMAIL = 'authorEmail';
    private const PRIORITY = 'priority';
    private const ATTACHMENT = 'attachment';

    public function __construct()
    {
        $this->add([
            'name' => self::TITLE,
            'required' => true,
            'filters' => [
                ['name' => StringTrim::class],
                ['name' => StripTags::class],
                ['name' => StripNewlines::class],
                ['name' => ToNull::class]
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'options' => [
                        'encoding' => 'UTF-8',
                        'min' => 3,
                        'max' => 255
                    ]
                ]
            ]
        ]);

        $this->add([
            'name' => self::CONTENT,
            'required' => false,
            'filters' => [
                ['name' => StringTrim::class],
                ['name' => StripTags::class],
                ['name' => StripNewlines::class],
                ['name' => ToNull::class]
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'options' => [
                        'encoding' => 'UTF-8',
                        'min' => 1,
                        'max' => 2047
                    ]
                ]
            ]
        ]);

        $this->add([
            'name' => self::CREATION_DATE,
            'required' => false,
            'filters' => [
                ['name' => StringTrim::class],
                ['name' => StripTags::class],
                ['name' => StripNewlines::class],
                ['name' => ToNull::class]
            ],
            'validators' => [
                [
                    'name' => Date::class,
                    'options' => [
                        'format' => 'd.m.Y'
                    ]
                ]
            ]
        ]);

        $this->add([
            'name' => self::AUTHOR_EMAIL,
            'required' => true,
            'filters' => [
                ['name' => StringTrim::class],
                ['name' => StripTags::class],
                ['name' => StripNewlines::class],
                ['name' => ToNull::class]
            ],
            'validators' => [
                [
                    'name' => EmailAddress::class,
                    'options' => [
                        'allow' => Hostname::ALLOW_DNS
                    ]
                ]
            ]
        ]);

        $this->add([
            'name' => self::PRIORITY,
            'required' => false,
            'filters' => [
                ['name' => Digits::class],
                ['name' => ToInt::class]
            ],
            'validators' => [
                [
                    'name' => GreaterThan::class,
                    'options' => [
                        'min' => 0
                    ]
                ],
                [
                    'name' => LessThan::class,
                    'options' => [
                        'max' => 5
                    ]
                ]
            ]
        ]);

        $this->add([
            'name' => self::ATTACHMENT,
            'required' => false,
            'filters' => [
                [
                    'name' => RenameUpload::class,
                    'options' => [
                        'target' => './public/upload/',
                        'use_upload_name' => true,
                        'randomize' => true
                    ]
                ]
            ],
            'validators' => [
                [
                    'name' => UploadFile::class,
                    'break_chain_on_failure' => true,
                ],
                [
                    'name' => Extension::class,
                    'break_chain_on_failure' => true,
                    'options' => [
                        'extension' => ['pdf']
                    ]
                ],
                [
                    'name' => MimeType::class,
                    'break_chain_on_failure' => true,
                    'options' => [
                        'mimeType' => ['application/pdf']
                    ]
                ],
                [
                    'name' => FilesSize::class,
                    'break_chain_on_failure' => true,
                    'options' => [
                        'max' => '10MB'
                    ]
                ]
            ]
        ]);
    }
}

Do dyspozycji jest sześć pól, z których dwa są wymagane. Określa to pole required. Domyślna jego wartość to true. W takim przypadku pole z wymienioną nazwą musi znaleźć się w dostarczonych danych. Jeśli natomiast ustawione jest na false, filtry i walidatory są uruchamiane tylko w momencie, gdy pole z taką nazwą istnieje.

Teraz kwestia omówienia wykorzystanych filtrów i walidatorów. Filtry to klasy, które przekształcają dostarczane dane. Myślę, że ich nazwy są dość wymowne. I tak na przykład:

  • StringTrim przytnie puste znaki na początku i końcu treści,
  • StripTags wytnie tagi języka HTML,
  • StripNewlines wytnie znaki nowej linii,
  • Digits wytnie wszystkie znaki, które nie są cyframi,
  • ToNull zadba o to by tak zwane puste wartości zostały sparsowane do wartości typu null,
  • ToInt sparsuje dane do wartości typu integer,
  • RenameUpload pozwoli zmienić nazwę i ścieżkę przesłanego pliku.

Za to walidatory to klasy, które są odpowiedzialne za sprawdzenie, czy przesłane dane są poprawne. I tak na przykład:

  • StringLength sprawdzi, czy ciąg znaków ma odpowiednią długość,
  • Date sprawdzi, czy ciąg znaków ma odpowiedni format daty,
  • EmailAddress sprawdzi, czy ciąg znaków ma odpowiedni format adresu e-mail,
  • GreaterThan sprawdzi, czy liczba jest większa niż minimalna wartość,
  • LessThan sprawdzi, czy liczba jest mniejsza niż maksymalna wartość,
  • UploadFile sprawdzi, czy plik został poprawnie przesłany za pomocą metody POST,
  • Extension sprawdzi, czy plik ma poprawne rozszerzenie,
  • MimeType sprawdzi, czy plik ma poprawny typ,
  • FilesSize sprawdzi, czy wielkość pliku nie jest zbyt duża.

Zwróćcie uwagę na opcję break_chain_on_failure. Kiedy jest ustawiona na true, walidacja zostanie przerwana w momencie, gdy podana wartość nie jest poprawna. Opcja bardzo przydatna i domyślnie ustawiona na false. To tylko niektóre, ale dość często używane filtry i walidatory. Jak widzicie bardzo łatwo się z nich korzysta i nie trzeba tworzyć ich samemu od zera.

Oprócz szerokiej gammy filtrów i walidatorów, w prosty sposób można pisać własne i używać ich w analogiczny sposób. Wystarczy rozszerzyć klasę AbstractValidator i zaimplementować obsługę metody isValid(). Poniżej prezentuję przykładowy własny walidator sprawdzający, czy data końca nie jest wcześniejsza, niż data początku.

class DateRangeValidator extends AbstractValidator
{
    private const EMPTY_DATE = 'emptyDate';
    private const END_EARLIER_THAN_START = 'endEarlierThanStart';

    protected $messageTemplates = [
        self::EMPTY_DATE => 'Field is required',
        self::END_EARLIER_THAN_START => 'End date cannot be earlier than start date'
    ];

    public function isValid($value, $context = null): bool
    {
        if (empty($value)) {
            $this->error(self::EMPTY_DATE);

            return false;
        }

        if (new \DateTime($context['startDate']) > new \DateTime($value)) {
            $this->error(self::END_EARLIER_THAN_START);

            return false;
        }

        return true;
    }
}

Wspomniałem wcześniej, że z tych komponentów można korzystać też w aplikacjach, które z Laminasem nie mają nic wspólnego. Oczywiście nie za pomocą rozszerzania klasy InputFilter. W praktyce jednak taką klasę jak ArticleInputFilter można stworzyć nawet bez rozszerzania InputFilter. Ale pokaże też opcję użycia, która różni się od powyższej. Ona również może okazać się funkcjonalna. Za przykład posłuży pole priorytetu i jego filtrowanie oraz walidacja analogiczna jak w powyższym kodzie.

class PriorityController
{
    public function priorityAction()
    {
        // fetch data from HTTP POST
        
        $priority = (new Digits())->filter($priorityFromRequest);
        $priority = (new ToInt())->filter($priority);

        $validatorChain = new ValidatorChain();
        $validatorChain
            ->attach(new GreaterThan(['min' => 0]))
            ->attach(new LessThan(['max' => 5]));

        if (!$validatorChain->isValid($priority)) {
            $errorMessages = $validatorChain->getMessages();

            // return response with error messages
        }

        $validPriority = $priority;

        // do something with priority valid data
    }
}

Podsumowanie

Najważniejsze przesłanie tego artykułu – nie należy odpuszczać walidowania danych. Niezależnie od tego, czy zaprezentowany rodzaj walidacji Wam odpowiada, czy preferujecie inny. Tak samo jak w aplikacjach pilnuje się reguł biznesowych, tak samo powinno się zadbać o dane na wejściu i na wyjściu – po to by zagwarantować sobie w pełni kontrolowane środowisko.

Walidacja danych w PHP dawniej nie była standardem i można było spotkać masę serwisów, które były dziurawe. Zresztą aktualnie też się to zdarza, ale na szczęście już rzadziej. Chcąc zagwarantować odpowiednią jakość projektu – walidacja jest niezbędna.

I na sam koniec, zainteresowanych tematem odsyłam do dwóch źródeł:

Udostępnij
Autor:  Krystian Żądło
Programista PHP, pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Autor bloga i marki Koddlo. Fan dobrego humoru oraz podcastów.