
Что же такое декларативное программирование? Википедия подскажет нам:
Декларати́вное программи́рование — это парадигма программирования, в которой задается спецификация решения задачи, то есть описывается, что представляет собой проблема и ожидаемый результат.
Далее в статье пойдет речь о том, как использовать данную парадигму в современном web-программировании. В частности, я хотел бы затронуть вопрос о валидации/верификации входных данных для веб сервисов. Примеры будут на php, так как это язык мне наиболее близок в профессиональном плане.
Простая форма
Итак, начнем с простого — обработка данных web-форм. Тема давно заезженная, знаю, но тем не менее. Предположим, у нас есть форма авторизации пользователя на сайте:

В данном случае, на сервере у нас будет некий endpoint, который будет обрабатывать запросы от этой формы. Сразу небольшая оговорка — речь идет о RESTful сервисе, т.е. форма в данном случае обрабатывается JS приложением. Давайте попробуем ее описать:
- Принимает данные POST запросом
- Ожидает контент типа
application/x-www-form-urlencoded - Принимает 2 параметра — username и пароль
- Возвращает
application/json; charset=utf-8 - Должен возвращать “внятный” ответ в случае ошибки авторизации
- В случае успешной авторизации — вернуть данные профиля пользователя
Swagger (Open API standard)
Такое описание отлично подходит для тестировщика из вашей команды, но вряд ли легко поддается автоматизации. А с этим нам поможет Swagger — инструмент для разработки и тестирования API. Swagger основан на JSON-schema (о нем мы еще поговорим ниже), открытом стандарте описания JSON объектов.
Декларация
Если взять за основу предложенный выше список и “перевести” его в Swagger формат, то мы получим нечто подобное:
{ "swagger": "2.0", "host": "example.com/login.php", "basePath": "/v1", "tags": [{"name": "login", "description": "User login form"}], "schemes": ["http", "https"], "paths": { "/user/login": { "post": { "tags": ["login"], "summary": "Authenticate the user", "consumes": ["application/x-www-form-urlencoded"], "produces": ["application/json; charset=utf-8"], "parameters": [ { "in": "formData", "name": "email", "description": "User email", "required": true, "schema": {"type": "string","maxLength": 50,"format": "email"} }, { "in": "formData", "name": "password", "description": "User password", "required": true, "schema": {"type": "string","maxLength": 16,"minLength": 8} } ], "responses": { "200": { "description": "successful login", "schema": { "type": "object", "properties": [ { "name": "status", "schema": {"type": "string"} } ], "example": {"status": "ok"} } }, "422": { "description": "Invalid login data", "schema": { "type": "object", "properties": [ {"name": "status","schema": {"type": "string"}}, {"name": "code","schema": {"type": "integer"}} ], "example": {"status": "fail","code": 12345} } } } } } } }
Итак, имея swagger спецификацию нашего endpoint-а, мы можем начать тестировать back-end без готового front-end-а, что зачастую значительно ускоряет и упрощает взаимодействие внутри команды. Используя Swagger UI, можно генерировать запросы к back-end-у прямо в браузере.

NOTE: для этого необходимо разместить файлы Swagger UI на одном домене с вашим бекендом или разрешить на вышеупомянутом крос-доменные запросы. Шпаргалка по CORS.Наверное самая приятная часть в декларации Swagger это возможность переиспользовать одинаковые объекты через definitions. В данном примере мы их не коснулись, но они есть в примерах на официальном сайте. Так как Swagger основан на JSON schema, мы рассмотрим пример defnitions ниже, когда будем валидировать JSON данные.
В случае со сложными входными данными, есть очень удобная возможность указать пример для конкретного объекта. Если использовать Swagger UI, он будет автоматически подставлен в форму для тестирования, что позволяет сократить время и вероятность ошибки не набирая все вручную.

http://petstore.swagger.io/#/user/createUsersWithArrayInput
Поддержка в IDE
Чтобы сделать работу со swagger файлом еще более приятной, можно установить плагин для вашей любимой IDE:
Мне не удалось найти плагина для NetBeans, хотя я почти уверен что он есть. Если вы знаете, где его взять — буду признателен за ссылку.
Генерация
Чтобы не превращать поддержку Swagger файла в отдельную монотонную и нудную задачу, можно использовать генератор Swagger JSON файла на основе вашего исходного кода. Таким образом мы убиваем сразу нескольких «зайцев»:
- У разработчиков всегда есть актуальная информация по входным данным в конкретный контроллер/метод
- Внешняя документация остается актуальной и меняется вместе с кодом
- Можно избежать создания
JSONфайла вручную и возможных ошибок в нем
Пример аннотации Swagger php
/** * @SWG\Post( * path="/product", * summary="Create/add a product", * tags={"product"}, * operationId="addProduct", * produces={"application/json"}, * consumes={"application/json"}, * @SWG\Parameter( * name="body", * in="body", * description="Create/alter product request", * required=true, * type="object", * @SWG\Schema(ref="#/definitions/Alteration") * ), * @SWG\Response( * response=201, * description="Product created", * @SWG\Schema(ref="#/definitions/Product") * ), * @SWG\Response( * response=400, * description="Empty data - nothing to insert", * @SWG\Schema(ref="#/definitions/Error") * ), * @SWG\Response( * response=422, * description="Product with the specified title already exists", * @SWG\Schema(ref="#/definitions/Error") * ) * ) */
Пример команды для генерации JSON файла:
./vendor/bin/swagger --output wwwroot/swagger.json // сохранить в public директорию --exclude vendor/ // исключить файлы библиотек
Подытожим: использовав Swagger мы задекларировали, как будет работать наш enpoint для внешнего мира.
Имея такой промежуточный UI можно генерировать разного рода входные данные для нашего enpoint-а и убедиться что он работает именно так, как задумывалось. На данном этапе наш UI “сэмулирован”, переходим к серверной части.
Для валидации и очистки данных в декларативной манере отлично подходит нативная функция filter_var_array:
$data = filter_var_array($_REQUEST, [ 'email' => FILTER_SANITIZE_ENCODED, 'password' => FILTER_SANITIZE_ENCODED ]); $result = (false === $data) ? ['status' => 'fail', 'code' => 12345] : ['status' => 'ok']; die(json_encode($result));
Понятно, что этот пример очень примитивен. В теперь перейдем к более сложному примеру.
JSON
Для валидации JSON данных, будем использовать все ту же JSON-schema. Предположим нам надо собрать данные с учеников школы для дальнейшего оперативного использования. Форма будет содержать информацию об ученике, родителях, контактные данные. Валидировать же данные мы будем библиотекой justinrainbow/json-schema.
А вот и наша схема:
{ "$schema": "http://json-schema.org/draft-04/schema#", "title": "EntryPoll", "type": "object", "definitions": { "contacts": { "type": "object", "properties": { "email": {"type": "string", "format": "email"}, "phone": {"type": "string", "pattern": "^\\+7\\(845\\)[0-9]{3}-[0-9]{2}-[0-9]{2}$"} } }, "name": { "type": "object", "properties": { "firstName": {"type": "string"}, "lastName": {"type": "string"}, "gender": {"type": "string", "enum": ["m", "f", "n/a"]} }, "required": ["firstName", "lastName"] } }, "properties": { "student": { "type": "object", "description": "The person who will be attending classes", "properties": { "name": {"$ref": "#definitions/name"}, "contacts": {"$ref": "#definitions/contacts"}, "dob": {"type": "string","format": "date"} }, "required": ["name", "dob"] }, "parents": { "type": "array", "minItems": 1, "maxItems": 3, "items": { "type": "object", "properties": { "name": {"$ref": "#definitions/name"}, "contacts": {"$ref": "#definitions/contacts"}, "relation": { "type": "string", "enum": ["father", "mother", "grandfather", "grandmother", "sibling", "other"] } }, "required": ["name", "contacts"] } }, "address": { "type": "object", "description": "The address where the family lives (not the legal address)", "properties": { "street": {"type": "string"}, "number": {"type": "number"}, "flat": {"type": "number"} } }, "legal": { "type": "boolean", "description": "The allowance to use submitted personal data" } }, "required": ["student", "address", "legal"] }
Формат JSON-schema поддерживает множество типов данных, начиная простыми string и int, заканчивая сложными и широко распространенными типами данных: date-time, email, hostname, ipv4, ipv6, uri, json-pointer. В итоге, из простых “кирпичиков” можно построить достаточно сложные формы.
Пример php кода для валидации:
(new \JsonSchema\Validator())->validate( json_decode($request->getBody()->getContents()), // содержимое запроса (object) ['$ref' => 'file://poll-schema.json'], // наша схема // выбрасывать Exception при ошибке \JsonSchema\Constraints\Constraint::CHECK_MODE_EXCEPTIONS );
XML
Тут все гораздо проще, чем с JSON-ом, но почему-то большАя часть разработчиков, с которыми мне довелось работать, либо не знают о такой возможности, либо просто ее игнорируют. Нам понадобятся нативные DOMDocument и расширение lib-xml, доступное по умолчанию в большинстве сборок php.
Для начала, сформируем пример запроса, который мы будем валидировать. Предположим у нас есть сервис платежной системы со сложной конфигурацией и мы хотим отправить на него запрос для формирования ссылки пользовательского интерфейса. Запрос будет включать информацию о разрешенных платежных системах, стоимости подписки, информации пользователя и т.д.
<?xml version="1.0" encoding="UTF-8"?> <paymentRequest> <forwardUrl>https://www.example.com</forwardUrl> <language>EN</language> <userId>13339</userId> <affiliateId>my:google:campain:5478669</affiliateId> <userIP>192.168.8.68</userIP> <tosUrl>https://www.example.com/tos</tosUrl> <contracts> <contract name="14-days-test"> <description>14 days test</description> <note>Is automatically converted into a basic package after expiration</note> <termOfContract period="days">14</termOfContract> <contractRenewalTerm period="month">1</contractRenewalTerm> <cancellationPeriod period="days">14</cancellationPeriod> <paytypes> <currency type="EUR"> <creditCard risk="0"/> <directDebit risk="100"/> <paypal risk="85"/> </currency> <currency type="USD"> <creditCard risk="58"/> </currency> </paytypes> <items> <item sequence="0"> <description>14 days test</description> <dueDate>now</dueDate> <amount paytype="creditCard" currency="EUR">1.9</amount> <amount paytype="directDebit" currency="EUR">1.9</amount> <amount paytype="paypal" currency="EUR">1.9</amount> <amount currency="USD">19.9</amount> </item> </items> </contract> </contracts> </paymentRequest>
А теперь провалидируем полученный запрос:
function validateString($xml = '') { $dom = new \DOMDocument('1.0', 'UTF-8'); // load, validate and try to catch error if (false === $dom->loadXML($xml) || false === $dom->schemaValidate($this->schema)) { $exception = new ValidationException('Invalid XML provided'); $this->cleanUp(); throw $exception; } return true; }
“Из коробки” у нас есть поддержка следующих типов: xs:string, xs:decimal, xs:integer, xs:boolean, xs:date, xs:time и еще несколько. Но хорошая новость в том, что мы ими не ограничены — можно создавать свои типы данных расширяя или сужая существующие, комбинируя их и прочее-прочее. Ниже приведен пример схемы для вышеуказанного XML запроса:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE schema SYSTEM "https://www.w3.org/2001/XMLSchema.dtd"> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:include schemaLocation="xsd/paymentRequest.xml" /> <xs:element name="paymentRequest" type="PaymentRequest" /> </xs:schema>
Данный документ содержит объявление родительского элемента. Рассмотрим дочерний, подключаемый документ отдельно:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE schema SYSTEM "https://www.w3.org/2001/XMLSchema.dtd"> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:include schemaLocation="type/urls.xsd" /> <xs:include schemaLocation="type/bank.xsd" /> <xs:include schemaLocation="type/address.xsd" /> <xs:include schemaLocation="type/ip.xsd" /> <xs:include schemaLocation="type/contract.xsd" /> <xs:include schemaLocation="type/voucher.xsd" /> <xs:include schemaLocation="type/riskinfo.xsd" /> <xs:include schemaLocation="enum/layouts.xsd" /> <xs:include schemaLocation="enum/schemes.xsd" /> <xs:include schemaLocation="enum/languages.xsd" /> <xs:complexType name="PaymentRequest"> <xs:annotation> <xs:documentation>Initial create enrollment request</xs:documentation> </xs:annotation> <xs:all> <xs:element name="tosUrl" type="TosUrl" /> <xs:element name="serviceHotline" type="xs:string" minOccurs="0" /> <xs:element name="userId"> <xs:simpleType> <xs:union> <xs:simpleType> <xs:restriction base='xs:string'> <xs:minLength value="1" /> </xs:restriction> </xs:simpleType> <xs:simpleType> <xs:restriction base='xs:integer' /> </xs:simpleType> </xs:union> </xs:simpleType> </xs:element> <xs:element name="userIP" type="ipv4" /> <xs:element name="contracts" type="ContractsList" /> <xs:element name="layout" type="AvailableLayouts" minOccurs="0" default="default" /> <xs:element name="colorScheme" type="AvailableSchemes" minOccurs="0" default="default" /> <xs:element name="forwardUrl" type="ForwardUrl" minOccurs="0" /> <xs:element name="language" type="xs:string" minOccurs="0" default="DE" /> <xs:element name="affiliateId" type="xs:string" minOccurs="0" /> <xs:element name="voucher" type="xs:string" minOccurs="0" /> <xs:element name="userBirth" type="xs:date" minOccurs="0" /> <xs:element name="userAddress" type="UserAddress" minOccurs="0" /> <xs:element name="userBankaccount" type="BankAccount" minOccurs="0" /> <xs:element name="userRiskInfo" type="UserRiskInfo" minOccurs="0" /> <xs:element name="vouchers" type="VouchersList" minOccurs="0" /> <xs:element name="voucherCodes" type="VoucherCodesList" minOccurs="0" /> </xs:all> </xs:complexType> </xs:schema>
В качестве еще одного бонуса — вы можете подключать (include-ить) XSD документы один в другой. Таким образом, один раз задекларировав некий кастомный тип данных можно его потом использовать в нескольких схемах. Подробнее, опять же, смотрите в репозитории с примерами. А еще можно включать комментарии с документацией прямо в тело документа.
В последнем примере мы подключаем еще целый ворох более мелких XSD. Как вы видите, описание сложных объектов может включать как сложные составные типы, так и более простые, базовые. Дабы полностью раскрыть тему, рассмотри пример одного из простых составных типов:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE schema SYSTEM "https://www.w3.org/2001/XMLSchema.dtd"> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:vc="http://www.w3.org/2007/XMLSchema-versioning" vc:minVersion="1.0"> <xs:simpleType name="ipv4"> <xs:annotation> <xs:documentation>An IP version 4 address.</xs:documentation> </xs:annotation> <xs:restriction base="xs:token"> <xs:pattern value="(25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])"/> <xs:pattern value="[0-9A-Fa-f]{8}"/> </xs:restriction> </xs:simpleType> </xs:schema>
Один из приятных моментов работы с XSD схемами заключается в том, что существует он уже довольно давно и при желании можно найти целые библиотеки кем-то составленных и проверенных пользовательских типов данных. В частности, пример выше взят из email рассылки от Декабря 2005 года.
Обработка ошибок LibXML
Я думаю что я не единственный, кого не устроила бы ошибка “Invalid XML provided”, особенно если речь идет например об инструментах для отладки и тестирования. А посему, давайте немного расширим информацию об ошибках в документе. В итоге мы хотим получить внятное сообщение для дальнейших действий и номер строки, содержащей ошибку.
/** * @link http://php.net/manual/en/domdocument.schemavalidate.php */ class Xml { /** * @var string */ protected $schema; /** * @var bool */ protected $errors; /** * Xml constructor. * @param string $schemaPath */ public function __construct($schemaPath = null) { $this->schema = null === $schemaPath ? __DIR__ . '/../config.xsd' : $schemaPath; $this->errors = libxml_use_internal_errors(true); } /** * Restore the values and remove errors */ protected function cleanUp() { libxml_use_internal_errors($this->errors); libxml_clear_errors(); } /** * @param string $xml * @return bool * @throws InvalidArgumentException * @throws ValidationException */ public function validateString($xml = '') { $dom = new \DOMDocument('1.0', 'UTF-8'); // load and try to catch error if (false === @$dom->loadXML($xml) || false === @$dom->schemaValidate($this->schema) ) { $exception = new ValidationException('Invalid XML provided'); $exception->setErrorCollection(new ErrorCollection(libxml_get_errors())); $this->cleanUp(); throw $exception; } return true; } }
Код сознательно сокращен, для удобства чтения. Основная идея в том, чтобы собрать ошибки libxml, “завернуть” их в кастомную коллекцию из специальных классов с информацией об ошибке. Классы и коллекция в свою очередь являются реализацией \JsonSerializable, чтобы можно было передавать их клиенту с нужной степенью доступности информации. Например мы исключили из стандартного \LibXMLError информацию о файле, в котором произошла ошибка.
/** * Decorator for native LibXmlError to hide file path. */ class LibXMLError implements \JsonSerializable { /** * @var int */ protected $code; /** * @var int */ protected $line; /** * @var string */ protected $message; /** * LibXMLError constructor. * @param \LibXMLError $error */ public function __construct(\LibXMLError $error = null) { if (null !== $error) { $this->line = $error->line; $this->message = $error->message; $this->code = $error->code; } } /** * @return array */ public function jsonSerialize() { return [ 'code' => $this->code, 'message' => $this->message, 'line' => $this->line, ]; } }
Тестирование
Как мы уже говорили, в случае использования Swagger, мануальное тестирование можно проводить прямо в браузере в Swagger UI. А дабы автоматизировать тестирование валидации, можно написать 2 очень простых теста. Для написания юнит тестов будем использовать phpUnit. Код приведу только для XML, но такой же подход отлично портируется и на JSON:
Проверка отсутствия ошибок на валидных XML/JSON
class SuccessfulTest extends \PHPUnit_Framework_TestCase { public function setUp() { $this->validator = new XmlValidator(XSD_SCHEMA_PATH); } public function tearDown() { $this->validator = null; } /** * @param string $filename * @param string $xml * @dataProvider generateValidDomDocuments */ public function testValidateXmlExample($filename, $xml = '') { try { $this->assertTrue($this->validator->validateString($xml)); } catch (ValidationException $ve) { $this->fail($ve->getMessage().' => '.json_encode($ve, JSON_PRETTY_PRINT)); } } public function generateValidDomDocuments() { $xml = []; $directory = new \DirectoryIterator('valid-xmls-folder'); /** @var \DirectoryIterator $file */ foreach ($directory as $file) { // skip non relevant if ($file->isDot() || !$file->isDir() || 'xml' === $file->getExtension()) { continue; } $xml[] = [$file->getBasename(), file_get_contents($file->getRealPath())]; } return $xml; } }
Проверка присутствия ожидаемой ошибки для невалидных XML/JSON
class SuccessfulTest extends \PHPUnit_Framework_TestCase { public function setUp() { $this->validator = new XmlValidator(XSD_SCHEMA_PATH); } public function tearDown() { $this->validator = null; } /** * @param string $filename * @param string $xml * @expectedException \Validator\Exception\ValidationException * @expectedExceptionCode 422 * @dataProvider generateInvalidDomDocuments */ public function testValidateXmlExample($filename, $xml = '') { $this->assertFalse($this->validator->validateString($xml)); } /** * @return array */ public function generateInvalidDomDocuments() { $xml = []; $directory = new \DirectoryIterator('valid-xmls-folder'); /** @var \DirectoryIterator $file */ foreach ($directory as $file) { // skip non relevant if ($file->isDot() || !$file->isDir() || 'xml' === $file->getExtension()) { continue; } $xml[] = [$file->getBasename(), file_get_contents($file->getRealPath())]; } return $xml; } }
На этом у меня все. Приятной вам декларации!
P.S. Буду признателен, за дополнения/замечания.
