Декларативное программирование в web-е

в 15:15, , рубрики: api, json-schema, php, swagger, XML, декларативное программирование, Программирование, Разработка веб-сайтов

image

Что же такое декларативное программирование? Википедия подскажет нам:

Декларати́вное программи́рование — это парадигма программирования, в которой задается спецификация решения задачи, то есть описывается, что представляет собой проблема и ожидаемый результат.

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

Простая форма

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

image

В данном случае, на сервере у нас будет некий 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-у прямо в браузере.

image

NOTE: для этого необходимо разместить файлы Swagger UI на одном домене с вашим бекендом или разрешить на вышеупомянутом крос-доменные запросы. Шпаргалка по CORS.

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

В случае со сложными входными данными, есть очень удобная возможность указать пример для конкретного объекта. Если использовать Swagger UI, он будет автоматически подставлен в форму для тестирования, что позволяет сократить время и вероятность ошибки не набирая все вручную.

image
http://petstore.swagger.io/#/user/createUsersWithArrayInput

Поддержка в IDE

Чтобы сделать работу со swagger файлом еще более приятной, можно установить плагин для вашей любимой IDE:

Мне не удалось найти плагина для NetBeans, хотя я почти уверен что он есть. Если вы знаете, где его взять — буду признателен за ссылку.

Генерация

Чтобы не превращать поддержку Swagger файла в отдельную монотонную и нудную задачу, можно использовать генератор Swagger JSON файла на основе вашего исходного кода. Таким образом мы убиваем сразу нескольких «зайцев»:

  • У разработчиков всегда есть актуальная информация по входным данным в конкретный контроллер/метод
  • Внешняя документация остается актуальной и меняется вместе с кодом
  • Можно избежать создания JSON файла вручную и возможных ошибок в нем

Пример аннотации Swagger php

/**
 * @SWGPost(
 *     path="/product",
 *     summary="Create/add a product",
 *     tags={"product"},
 *     operationId="addProduct",
 *     produces={"application/json"},
 *     consumes={"application/json"},
 *     @SWGParameter(
 *         name="body",
 *         in="body",
 *         description="Create/alter product request",
 *         required=true,
 *         type="object",
 *         @SWGSchema(ref="#/definitions/Alteration")
 *     ),
 *     @SWGResponse(
 *         response=201,
 *         description="Product created",
 *         @SWGSchema(ref="#/definitions/Product")
 *     ),
 *     @SWGResponse(
 *         response=400,
 *         description="Empty data - nothing to insert",
 *         @SWGSchema(ref="#/definitions/Error")
 *     ),
 *     @SWGResponse(
 *         response=422,
 *         description="Product with the specified title already exists",
 *         @SWGSchema(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 JsonSchemaValidator())->validate(
   json_decode($request->getBody()->getContents()), // содержимое запроса
   (object) ['$ref' => 'file://poll-schema.json'],  // наша схема
   // выбрасывать Exception при ошибке
   JsonSchemaConstraintsConstraint::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 ValidatorExceptionValidationException
     * @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. Буду признателен, за дополнения/замечания.

Автор: Игорь

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js