Зависимые типы на примере взаимодействия с Яндекс.Директ API
date = fromGregorian 2017 jan 06
category = "Веб"
tags = ["зависимые типы", "семейства типов", "web", "Servant"]
Здравствуйте.
Зависимые типы (The view from the left, What is a brief but complete explanation of a pure/dependent type system?) набирают популярность и в будущем будут добавлены в GHC. Однако, зависимые типы ассоциируются с доказательством теорем и формализацией различных систем, так что их польза может быть неочевидной для рядового программиста. В этом посте мы рассмотрим зависимые типы на примере простой программистской задачи – типобезопасного взаимодействия с Яндекс.Директ API v5.
Популяризации зависимых типов для программирования немало способствует библиотека Servant, используемая как для предоставления типизированного API (servant-server), так и для написания запросов к уже существующему (servant-client). Мы будем использовать эту библиотеку (объясняние принципа ее работы выходит за рамки данного поста, желающие могут ознакомиться с докладом Делаем свою жизнь проще с Servant). Однако, встроенными в Servant зависимыми типами дело не ограничится, и мы добавим еще своих.
План
Мы опишем несколько базовых сущностей Яндекс.Директ API: метод (add, get), операция, результат операции, объект (наименования словарей, объявления, кампании) и определим зависимо-типизированную функцию, которая позволяет вызвать любой метод, параметризованный некоторым объектом, и принять ответ, тип которого зависит от вызванного метода и типа объекта.
Операции
Яндекс.Директ API предоставляет довольно много методов, которые можно выполнять, поэтому выберем небольшое подмножество (я буду опускать deriving (...) везде):
data Method = Add | GetОперацией является метод с переданными ему параметрами:
data Operation a = Operation
  { method :: !Method
  , params :: !a
  }Каждая операция возвращает некоторый результат:
newtype Result r = Result
  { result :: r
  }Разные методы возвращают значения разных типов. В случае использования метода add, возвращается структура AddResults:
newtype AddResults = AddResults
  { getAddResults :: [ActionResult]
  }
data ActionResult = ActionResult
  { getId       :: !(Maybe Integer)
  , getWarnings :: !(Maybe [ExceptionNotification])
  , getErrors   :: !(Maybe [ExceptionNotification])
  }
data ExceptionNotification = ExceptionNotification
  { getCode    :: !Int
  , getMessage :: !Text
  , getDetails :: !(Maybe Text)
  }Если же используется метод get, то результат зависит от параметра, с которым метод был вызван. К примеру, если методу get был передан параметр типа DictionaryNames (по сути список названий словарей)
newtype DictionaryNames = DictionaryNames
  { getDictionaryNames :: [DictionaryNameEnum]
  }
data DictionaryNameEnum
  = GeoRegions
  | SupplySidePlatforms
  | ...то результатом будет Dictionaries (тип, изоморфный гетерогенному списку всех словарей, каждый из которых завернут в Maybe, так что для каждого словаря, который не был запрошен, будет возвращено Nothing):
data Dictionaries = Dictionaries
  { getGeoRegions          :: !(Maybe [GeoRegionsItem])
  , getSupplySidePlatforms :: !(Maybe [SupplySidePlatformsItem])
  , ...
  }
data GeoRegionsItem = GeoRegionsItem
  { getGeoRegionId   :: !Integer
  , getGeoRegionName :: !Text
  , getGeoRegionType :: !Text
  , getParentId      :: !(Maybe Integer)
  }
data SupplySidePlatformsItem = SupplySidePlatformsItem
  { getTitle :: !Text
  }Таким образом тип возвращаемого значения зависит от вызванного метода и от типа параметра, ему переданного. В коде это выглядит следующим образом:
type family ResultOf m a where
  ResultOf 'Add a               = AddResults
  ResultOf 'Get DictionaryNames = DictionariesПоскольку методы являются значениями, а Пи-типов в Haskell еще нет, мы должны использовать технику под названием singleton types, чтобы поднять на уровень типов то, что существует на уровне значений (на эту тему тоже был пост):
data SMethod m where
  SAdd :: SMethod 'Add
  SGet :: SMethod 'Get
evalSMethod :: SMethod m -> Method
evalSMethod SAdd = Add
evalSMethod SGet = GetОбъекты
Яндекс.Директ API является весьма разветвленным: с его помощью можно обрабатывать объявления, группы объявлений, кампании, расширения, ключевые слова и многое другое. Мы напишем обобщенную функцию, которая позволит передать вызываемому методу любой из этих объектов.
Сначала пару примеров. Объявления:
newtype Ads = Ads
  { getAds :: [AdAddItem]
  }
data AdAddItem = ...Кампании:
newtype Campaigns = Campaigns
  { getCampaigns :: [CampaignAddItem]
  }
data CampaignAddItem = ...Адресом для обработки запросов, имеющих отношение к объявлениям, является “https://api.direct.yandex.com/json/v5/ads”, а адресом для обработки запросов, имеющих отношение к кампаниям, является “https://api.direct.yandex.com/json/v5/campaigns”. Все остальные объекты также имеют собственные адреса для обработки запросов. Так что заведем класс типов
class ToJSON a => Entity a where
  entityName :: a -> String
instance Entity Ads where
  entityName _ = "ads"
instance Entity Campaigns where
  entityName _ = "campaigns"То есть каждый объект должен быть сериализуем в JSON, чтобы его можно было передать по сети, и иметь имя, которое составляет часть адреса для запросов.
API
Опишем API Директа с помощью инструментов, предоставляемых Servant:
-- https://tech.yandex.ru/direct/doc/dg/concepts/headers-docpage/
type DirectAPI a r = Header "Authorization"   Text
                  :> Header "Accept-Language" Text
                  :> Header "Client-Login"    Text
                  :> ReqBody '[JSON] (Operation a)
                  :> Post    '[JSON] (Result    r)a – тип параметра вызываемого метода, r – тип возвращаемого результата. Операция автоматически сериализуется в JSON, результат автоматически десериализуется из JSON.
Нам также понадобится Директ-специфичный конфиг:
data DirectConfig = DirectConfig
  { getToken :: !Text         -- https://tech.yandex.ru/direct/doc/dg/concepts/auth-token-docpage/
  , getLogin :: !(Maybe Text)
  , getHost  :: !Text         -- Может быть как "api.direct.yandex.com",
                              --          так и "api-sandbox.direct.yandex.com"
  }И мы наконец можем собрать все вместе и определить основную функцию для общения с API Директа:
perform :: (FromJSON (ResultOf m a), Entity a)
        => DirectConfig -> Manager -> SMethod m -> a -> ClientM (ResultOf m a)
perform (DirectConfig token login host) manager smethod entity = result <$> run where
  proxy = Proxy :: Proxy (DirectAPI a r)
  auth  = Just $ "Bearer " `mappend` token
  lang  = Just "ru"
  oper  = Operation (evalSMethod smethod) entity
  url   = BaseUrl Https (unpack host) 443 $ "/json/v5/" ++ entityName entity
  run   = client proxy auth lang login oper manager url(В новых версиях Servant Manager является частью ClientM, но на момент написания поста в последнем LTS-снимке на Stackage используется старая версия). perform принимает Директ-специфичный конфиг, менеджер соединения, метод, который следует вызвать, в форме синглтона и объект, который будет передан методу. Из сигнатуры perform видно, что тип возвращаемого значения (ResultOf m a) действительно зависит от вызванного метода (m в SMethod m) и от типа параметра (a), ему переданного. В остальном мы просто используемым базовый функционал, предоставляемый Servant.
Заключение
С помощью зависимо-типизированного Servant можно декларативно описать API, и избавиться от необходимости вручную сериализовать-десериализовать данные. Однако, типы данных, приходящих в ответ на запрос, могут зависеть от содержимого запроса, но зависимые типы и здесь позволяют выразить все необходимые инварианты.