Контрактный подход к построению

API зависимых приложений

Dolganov Sergey @ Evil Martians

Title Text

eBay for Business

Приложение, полностью построенное на API зависимостях

План

2. Контрактный подход

3. Каноничное решение для API

4. А мы как его применили?

5. А если сравнить?

6. Ссылки / рекомендации

1. Проблема? Задача!

Проблема? Задача!

Логистика для интернет магазина

Контракт — это:

— Pre-conditions (request)

— Post-conditions (response)

— Invariants (state)

Input

Output

function (input) { ... } # =>

State

Pre-conditions

Post-conditions

Invariants

Contract

Каноничное решение

Пишем контракт через схемы API

API Схемы

— XSD

— JSON Schema

— Open API (Swagger)

Схема запроса

<?xml version="1.0" encoding="UTF-8"?>
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://www.dhl.com" 
 xmlns:dct="http://www.dhl.com/DCTRequestdatatypes" 
 xmlns:dhl="..." targetNamespace="http://www.dhl.com" 
 elementFormDefault="unqualified">
   <xsd:import namespace="http://www.dhl.com/datatypes" schemaLocation="datatypes.xsd" />
   <xsd:import namespace="http://www.dhl.com/DCTRequestdatatypes" schemaLocation="... />
   <xsd:element name="DCTRequest">
      <xsd:complexType>
         <xsd:sequence>
            <xsd:choice minOccurs="1" maxOccurs="1">
               <xsd:element name="GetQuote">
                  <xsd:annotation>
                     <xsd:documentation>Root element of Quote request</xsd:documentation>
                  </xsd:annotation>
                  <xsd:complexType>
                     <xsd:sequence>
                        <xsd:element name="Request" type="dhl:Request" />
                        <xsd:element name="From" type="dct:DCTFrom" minOccurs="1" />
                        <xsd:element name="BkgDetails" minOccurs="1" type="dct:BkgDetailsType" />
                        <xsd:element name="To" minOccurs="1" type="dct:DCTTo" />
                        <xsd:element name="Dutiable" minOccurs="0" type="dct:DCTDutiable" />
                     </xsd:sequence>
                  </xsd:complexType>
               </xsd:element>
               <!-- .... -->
            </xsd:choice>
         </xsd:sequence>
      </xsd:complexType>
   </xsd:element>
</xsd:schema>

Схема запроса

<xsd:import namespace="http://www.dhl.com/datatypes" 
            schemaLocation="datatypes.xsd" />

Схема запроса

<?xml version="1.0" encoding="UTF-8"?>
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://www.dhl.com" 
 xmlns:dct="http://www.dhl.com/DCTRequestdatatypes" 
 xmlns:dhl="..." targetNamespace="http://www.dhl.com" 
 elementFormDefault="unqualified">
   <xsd:import namespace="http://www.dhl.com/datatypes" schemaLocation="datatypes.xsd" />
   <xsd:import namespace="http://www.dhl.com/DCTRequestdatatypes" schemaLocation="... />
   <xsd:element name="DCTRequest">
      <xsd:complexType>
         <xsd:sequence>
            <xsd:choice minOccurs="1" maxOccurs="1">
               <xsd:element name="GetQuote">
                  <xsd:annotation>
                     <xsd:documentation>Root element of Quote request</xsd:documentation>
                  </xsd:annotation>
                  <xsd:complexType>
                     <xsd:sequence>
                        <xsd:element name="Request" type="dhl:Request" />
                        <xsd:element name="From" type="dct:DCTFrom" minOccurs="1" />
                        <xsd:element name="BkgDetails" minOccurs="1" type="dct:BkgDetailsType" />
                        <xsd:element name="To" minOccurs="1" type="dct:DCTTo" />
                        <xsd:element name="Dutiable" minOccurs="0" type="dct:DCTDutiable" />
                     </xsd:sequence>
                  </xsd:complexType>
               </xsd:element>
               <!-- .... -->
            </xsd:choice>
         </xsd:sequence>
      </xsd:complexType>
   </xsd:element>
</xsd:schema>

Схема запроса


 <xsd:element name="From" type="dct:DCTFrom" minOccurs="1" />

Схема ответа

<?xml version="1.0" encoding="UTF-8"?>
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" 
 xmlns="http://www.dhl.com" xmlns:dct="http://www.dhl.com/DCTResponsedatatypes" 
 xmlns:dhl="http://www.dhl.com/datatypes" targetNamespace="http://www.dhl.com" 
 elementFormDefault="unqualified">
   <xsd:import namespace="http://www.dhl.com/datatypes" schemaLocation="config/xmlshipping/xsd/datatypes.xsd" />
   <xsd:import namespace="http://www.dhl.com/DCTResponsedatatypes" schemaLocation="config/xmlshipping/xsd/DCTResponsedatatypes.xsd" />
   <xsd:element name="DCTResponse">
      <xsd:complexType>
         <xsd:sequence>
            <xsd:choice minOccurs="1" maxOccurs="1">
               <xsd:element name="GetQuoteResponse">
                  <xsd:annotation>
                     <xsd:documentation>Root element of shipment validation request</xsd:documentation>
                  </xsd:annotation>
                  <xsd:complexType>
                     <xsd:sequence>
                        <xsd:element name="Response">
                           <xsd:complexType>
                              <xsd:annotation>
                                 <xsd:documentation>Generic response header</xsd:documentation>
                              </xsd:annotation>
                              <xsd:sequence>
                                 <xsd:element name="ServiceHeader" type="ServiceHeader" />
                              </xsd:sequence>
                           </xsd:complexType>
                        </xsd:element>
                        <xsd:element name="BkgDetails" minOccurs="0" type="dct:BkgDetailsType" maxOccurs="unbounded" />
                        <xsd:element name="Srvs" minOccurs="0" maxOccurs="1">
                           <xsd:complexType>
                              <xsd:sequence>
                                 <xsd:element name="Srv" type="dct:SrvType" minOccurs="0" maxOccurs="unbounded" />
                              </xsd:sequence>
                           </xsd:complexType>
                        </xsd:element>
                        <xsd:element name="Note" minOccurs="0" type="dct:NoteType" maxOccurs="unbounded" />
                     </xsd:sequence>
                  </xsd:complexType>
               </xsd:element>
               <xsd:element name="GetCapabilityResponse">
                  <xsd:annotation>
                     <xsd:documentation>Root element of shipment validation request</xsd:documentation>
                  </xsd:annotation>
                  <xsd:complexType>
                     <xsd:sequence>
                        <xsd:element name="Response">
                           <xsd:complexType>
                              <xsd:annotation>
                                 <xsd:documentation>Generic response header</xsd:documentation>
                              </xsd:annotation>
                              <xsd:sequence>
                                 <xsd:element name="ServiceHeader" type="ServiceHeader" />
                              </xsd:sequence>
                           </xsd:complexType>
                        </xsd:element>
                        <xsd:element name="BkgDetails" minOccurs="0" type="dct:BkgDetailsType" maxOccurs="unbounded" />
                        <xsd:element name="Srvs" minOccurs="0" maxOccurs="1">
                           <xsd:complexType>
                              <xsd:sequence>
                                 <xsd:element name="Srv" type="dct:SrvType" minOccurs="0" maxOccurs="unbounded" />
                              </xsd:sequence>
                           </xsd:complexType>
                        </xsd:element>
                        <xsd:element name="Note" minOccurs="0" type="dct:NoteType" maxOccurs="unbounded" />
                     </xsd:sequence>
                  </xsd:complexType>
               </xsd:element>
            </xsd:choice>
         </xsd:sequence>
      </xsd:complexType>
   </xsd:element>
   <xsd:complexType name="ServiceHeader">
      <xsd:annotation>
         <xsd:documentation>Standard routing header</xsd:documentation>
      </xsd:annotation>
      <xsd:sequence>
         <xsd:element name="MessageTime" type="xsd:dateTime" minOccurs="0">
            <xsd:annotation>
               <xsd:documentation>Time this message is sent</xsd:documentation>
            </xsd:annotation>
         </xsd:element>
         <xsd:element name="MessageReference" type="xsd:string" minOccurs="0">
            <xsd:annotation>
               <xsd:documentation>A string, peferably number, to uniquely identify individual messages. Minimum length must be
 28 and maximum length is 32</xsd:documentation>
            </xsd:annotation>
         </xsd:element>
         <xsd:element name="SiteID" type="xsd:string" />
      </xsd:sequence>
   </xsd:complexType>
</xsd:schema>

<xsd:element name="BkgDetails" 
             minOccurs="0" 
             type="dct:BkgDetailsType" 
             maxOccurs="unbounded" />

Схема ответа

Валидация на схемах

  
  def validate(document, schema_path, root_element)
    schema = Nokogiri::XML::Schema(File.read(schema_path))
    schema.validate(document.xpath("//#{root_element}").to_s)
  end

  def get_tariff(data)
    xml_request = convert_to_xml(data)
    errors = validate(xml_request, 
                      "./get_tariff_schemas/request.xsd", "container")
    raise InvalidRequest, errors unless errors.empty? 

    xml_response = get("GetQuote", xml_request)
    errors = validate(xml_response, 
                      "./get_tariff_schemas/response.xsd", "container")
    raise InvalidResponse, errors unless errors.empty?

    xml_response
  end

Зачем что-то еще?

        module RussianPost::Domestic::Calculator
          attr_reader :errors
          def initialize(parcel)
            @parcel = parcel
          end

          def call
            policy = InternalParcelPolicy[parcel]
            return if policy.invalid?

            policy = RuPostDomesticTariffPolicy[parcel]
            return if policy.invalid?

            response = RussianPost::API.get_tariff(parcel: parcel)
            mapped_response = GetTariffResponseMapper[response]

            policy = RuPostTariffResponsePolicy[response, mapped_response]
            return if policy.invalid?

            parcel.update_tariff!(russian_post: mapped_response)
          ensure
            @errors = policy.errors
          end
        end

        if (service = RussianPost::Calculator.new(parcel)).call
          render json: { tariff: parcel.reload.tariffs[:russian_post] }
        else
          render json: { errors: { base: service.errors } }
        end

Нужно сократить затраты на отладку...

В ФУНКЦИОНАЛЬНЫХ ЯЗЫКАХ СУЩЕСТВУЕТ ЭЛЕГАНТНОЕ РЕШЕНИЕ ДЛЯ ПАРСИНГА И ВАЛИДАЦИИ 

МОЖЕТ И В RUBY СРАБОТАЕТ?

Что, если вызов API — это просто функция?

   Class vs Type

      Method vs Function

OOP vs FP

Haskell

Haskell

Haskell

Пример на Haskell





data Parcel = Parcel { weight      :: ParcelWeight 
                     , value       :: ParcelValue
                     , itemsCount  :: ItemsCount
                     , origin      :: OriginAddress
                     , destination :: DestinationAddress }








requestTariff :: Parcel -> (IO XML -> IO XML) -> Either ContractFailure Response

OK (Response)

FAIL

(ContractFailure)

Пример на Haskell



let realParcel = Parcel { weight = 200, destination = "US, Brooklyn, 11201", ...}

case requestTariff realParcel (get' "GetQuote") of  
    Right KnownAPIError -> -- show error to user (to correct input for example)
    Right Tariff -> -- do something when got valid tariff
    Right TariffWithWarnings -> -- use Tariff but output warnings
    Left (ContractFailure context) -> -- send to Honeybadger with whole context
                     

Exhaustive Pattern Matching

Function Call

Refinement Types

— Type

— Predicate

{ n :: Integer | n > 5 }

Контекст, как мы поняли, что внутри не котенок

Применяем полученные знания

Пример: Запрос на добавление контакта в CRM

Реализация на основе

— Algebraic Data Types

— Refinement Types

Пример на Ruby

# types product
class ContactType < BaseType
  attribute :email, Contact::EmailType
  attribute :name,  Contact::NameType
  attribute :phone, Contact::PhoneType
end

# will just delegate validation to attributes
ContactType.match(data) # => #<ContactType ...> 
                        #    or #<ContractFailure ...>

class EmailType < BaseType
  # in more sophisticated cases – delegate
  def match
    @unpacked = ::Mail::Address.new(@value)
    self
  rescue Mail::Field::IncompleteParseError => ex
    ContractFailure.new(:email_parser_failed, ex)
  end
end

Пример на Ruby

# same but with composition (types sum) for response
AddContactResult = ContactAdded   |
                   ContactUpdated |
                   ErrorsType

ErrorsType = DuplicateError |
             RateLimitError |
             ParseError

class CRM::API::Client
  def add_contact(payload)
    result = ContactType.match(payload)
    return result if result.invalid?

    response = post("/Contacts", result.unpack)
    result = AddContactResult.match(response)
  ensure
    ApiSampler.register!(result)
  end
end

Пример на Ruby


result = CRM::API::Client.new.add_contact(payload) 

case result
when RateLimitError
  CRM::API::Client.cooldown(payload.token)
  return "Please, try again"
when ContractFailure
  Honeybadger.notify(result, context: {...})
  return "Sorry, API is not working correctly"
when ContactAdded
  return "Successfully added contact"
when ContactUpdated
  return "Contact was updated"
else
  raise "Unmatched behavior", result 
end

Пример на Ruby

  
  def add_contact(data)
    json_request = convert_to_json(data)
    errors = validate(json_request, 
                      "./add_contact_schemas/request.json")
    raise InvalidRequest, errors unless errors.empty? 

    json_response = post("/Contacts", json_request)
    errors = validate(json_response, 
                      "./add_contact_schemas/response.json")
    raise InvalidResponse, errors unless errors.empty?

    json_response
  end

Blood Contracts Core

— Refinement Types

— Composition of Types

cultofmartians.com

Схемы Типы
Сложность имплементации
Скорость работы
Переиспользуемость кода
Возможности отладки в production  

Сравним?

— Category Theory for Programmers

— Maybe Haskell

Рекомендуемая литература

Спасибо за внимание!

Contracts For API Dependent Applications

By Sergey Dolganov

Contracts For API Dependent Applications

Как применять контрактный подход в современном приложении? Конечно, для работы с API и валидации запросов и ответов. Однако, можно пойти дальше и использовать не только схемы запросов, но и Типы на уровне языка. А если применить еще пару приемов позаимствованных в функциональных языках, можно сделать работу вашего приложения не только надежной, но и прозрачной для отладки.

  • 1,055