Что же такое декларативное программирование? Википедия подскажет нам:
Декларати́вное программи́рование — это парадигма программирования, в которой задается спецификация решения задачи, то есть описывается, что представляет собой проблема и ожидаемый результат.
Далее в статье пойдет речь о том, как использовать данную парадигму в современном 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. Буду признателен, за дополнения/замечания.