Какие механизмы используются для включения API на основе типов Servant?

Я очень озадачен тем, как Слуга способен достичь волшебства, которое он делает, используя набор текста. Пример на веб-сайте уже очень озадачивает меня:

type MyAPI = "date" :> Get '[JSON] Date
        :<|> "time" :> Capture "tz" Timezone :> Get '[JSON] Time

Я получаю "дату", "время", [JSON] и "tz" - литералы уровня типа. Это ценности, которые стали "типами". Хорошо.

я понимаю :> а также :<|> являются операторами типа. Хорошо.

Я не понимаю, как эти вещи, после того как они стали типами, могут быть извлечены обратно в значения. Какой механизм для этого?

Я также не понимаю, как первая часть этого типа может заставить фреймворк ожидать функцию сигнатуры IO Dateили как вторая часть этого типа может заставить платформу ожидать функцию сигнатуры Timezone -> IO Time от меня. Как происходит это преобразование?

И как тогда фреймворк может вызвать функцию, для которой он изначально не знал тип?

Я уверен, что здесь есть ряд расширений GHC и уникальных функций, которые я не знаком с этим объединением, чтобы это волшебство произошло.

Может кто-нибудь объяснить, какие функции здесь задействованы и как они работают вместе?

1 ответ

Глядя на документ Слуги для полного объяснения может быть лучшим вариантом. Тем не менее, я попытаюсь проиллюстрировать подход, принятый Servant здесь, путем реализации "TinyServant", версии Servant, сведенной к минимуму.

Извините, что этот ответ так долго. Тем не менее, он все еще немного короче, чем бумага, и код, который здесь обсуждается, "всего" 81 строка, доступная также в виде файла на Haskell.

Препараты

Для начала вот языковые расширения, которые нам понадобятся:

{-# LANGUAGE DataKinds, PolyKinds, TypeOperators #-}
{-# LANGUAGE TypeFamilies, FlexibleInstances, ScopedTypeVariables #-}
{-# LANGUAGE InstanceSigs #-}

Первые три необходимы для определения самого DSL уровня типа. DSL использует строки уровня типа (DataKinds) а также использует добрый полиморфизм (PolyKinds). Использование инфиксных операторов уровня типа, таких как :<|> а также :> требует TypeOperators расширение.

Вторые три необходимы для определения интерпретации (мы определим нечто, напоминающее то, что делает веб-сервер, но без всей веб-части). Для этого нам нужны функции уровня типа (TypeFamilies), некоторый тип программирования классов, который потребует (FlexibleInstances), а также некоторые аннотации типов для проверки типов, которые требуют ScopedTypeVariables,

Чисто для документации мы также используем InstanceSigs,

Вот наш заголовок модуля:

module TinyServant where

import Control.Applicative
import GHC.TypeLits
import Text.Read
import Data.Time

После этих предварительных экзаменов мы готовы начать.

Спецификации API

Первым компонентом является определение типов данных, которые используются для спецификаций API.

data Get (a :: *)

data a :<|> b = a :<|> b
infixr 8 :<|>

data (a :: k) :> (b :: *)
infixr 9 :>

data Capture (a :: *)

Мы определяем только четыре конструкции на нашем упрощенном языке:

  1. Get a представляет и конечную точку типа a (в своем роде *). По сравнению с полным Servant, мы игнорируем типы контента здесь. Нам нужен тип данных только для спецификаций API. Теперь есть непосредственно соответствующие значения, и, следовательно, нет конструктора для Get,

  2. С a :<|> b Представляем выбор между двумя маршрутами. Опять же, нам не нужен конструктор, но оказывается, что мы будем использовать пару обработчиков для представления обработчика API, используя :<|>, Для вложенных приложений :<|> мы получили бы вложенные пары обработчиков, которые выглядят несколько уродливо, используя стандартную запись в Haskell, поэтому мы определяем :<|> Конструктор должен быть эквивалентен паре.

  3. С item :> rest, мы представляем вложенные маршруты, где item это первый компонент и rest остальные компоненты. В нашем упрощенном DSL есть только две возможности item: строка уровня типа или Capture, Потому что строки уровня типа имеют вид Symbol, но Capture, определенный ниже, имеет вид * мы делаем первый аргумент :> добрый-полиморфный, так что оба варианта принимаются системой добрых Haskell.

  4. Capture a представляет компонент маршрута, который захватывается, анализируется и затем предоставляется обработчику как параметр типа a, В полном слуге, Capture имеет дополнительную строку в качестве параметра, который используется для генерации документации. Мы опускаем строку здесь.

Пример API

Теперь мы можем записать версию спецификации API из вопроса, адаптированной к фактическим типам, встречающимся в Data.Time и к нашему упрощенному DSL:

type MyAPI = "date" :> Get Day
        :<|> "time" :> Capture TimeZone :> Get ZonedTime

Интерпретация как сервер

Самым интересным аспектом, конечно же, является то, что мы можем сделать с помощью API, и это также в основном вопрос.

Слуга определяет несколько интерпретаций, но все они следуют схожему образцу. Мы определим один здесь, который вдохновлен интерпретацией как веб-сервер.

У Слуги serve функция принимает прокси для типа API и обработчик, сопоставляющий тип API с WAI Application, который по сути является функцией от HTTP-запросов к ответам. Мы будем абстрагироваться от веб-части здесь и определить

serve :: HasServer layout
      => Proxy layout -> Server layout -> [String] -> IO String

вместо.

HasServer Класс, который мы определим ниже, имеет экземпляры для всех различных конструкций DSL уровня типа и поэтому кодирует то, что он означает для типа Haskell layout быть интерпретируемым как тип API сервера.

Proxy устанавливает связь между типом и уровнем значения. Это определяется как

data Proxy a = Proxy

и его единственная цель состоит в том, чтобы, передавая в Proxy конструктор с явно заданным типом, мы можем сделать его очень явным для того типа API, который мы хотим вычислить на сервере.

Server аргумент является обработчиком для API, Вот, Server сам по себе является семейством типов и вычисляет из типа API тип, который должен иметь обработчик (и). Это один из основных компонентов того, что заставляет Слугу работать правильно.

Список строк представляет запрос, сведенный к списку компонентов URL. В результате мы всегда возвращаем String ответ, и мы разрешаем использование IO, Full Servant использует несколько более сложные типы, но идея та же.

Server тип семьи

Мы определяем Server как тип семьи в первую очередь. (В Servant используется фактическое семейство типов ServerT и определяется как часть HasServer учебный класс.)

type family Server layout :: *

Обработчик для Get a конечная точка просто IO действие, производящее a, (Еще раз, в полном коде Servant у нас есть немного больше вариантов, таких как выдача ошибки.)

type instance Server (Get a) = IO a

Обработчик для a :<|> b это пара обработчиков, поэтому мы могли бы определить

type instance Server (a :<|> b) = (Server a, Server b) -- preliminary

Но, как указано выше, для вложенных случаев :<|> это приводит к вложенным парам, которые выглядят несколько лучше с конструктором инфиксных пар, поэтому вместо этого Servant определяет эквивалент

type instance Server (a :<|> b) = Server a :<|> Server b

Осталось объяснить, как обрабатывается каждый из компонентов пути.

Литеральные строки в маршрутах не влияют на тип обработчика:

type instance Server ((s :: Symbol) :> r) = Server r

Захват, однако, означает, что обработчик ожидает дополнительный аргумент типа захвата:

type instance Server (Capture a :> r) = a -> Server r

Вычисление типа обработчика примера API

Если мы расширим Server MyAPI, мы получаем

Server MyAPI ~ Server ("date" :> Get Day
                  :<|> "time" :> Capture TimeZone :> Get ZonedTime)
             ~      Server ("date" :> Get Day)
               :<|> Server ("time" :> Capture TimeZone :> Get ZonedTime)
             ~      Server (Get Day)
               :<|> Server ("time" :> Capture TimeZone :> Get ZonedTime)
             ~      IO Day
               :<|> Server ("time" :> Capture TimeZone :> Get ZonedTime)
             ~      IO Day
               :<|> Server (Capture TimeZone :> Get ZonedTime)
             ~      IO Day
               :<|> TimeZone -> Server (Get ZonedTime)
             ~      IO Day
               :<|> TimeZone -> IO ZonedTime

Как и предполагалось, серверу для нашего API требуется пара обработчиков, один из которых предоставляет дату, а другой - с учетом часового пояса - время. Мы можем определить их прямо сейчас:

handleDate :: IO Day
handleDate = utctDay <$> getCurrentTime

handleTime :: TimeZone -> IO ZonedTime
handleTime tz = utcToZonedTime tz <$> getCurrentTime

handleMyAPI :: Server MyAPI
handleMyAPI = handleDate :<|> handleTime

HasServer учебный класс

Нам еще предстоит реализовать HasServer класс, который выглядит следующим образом:

class HasServer layout where
  route :: Proxy layout -> Server layout -> [String] -> Maybe (IO String)

Задача функции route почти как serve, Внутренне мы должны отправить входящий запрос на правильный маршрутизатор. В случае :<|> это означает, что мы должны сделать выбор между двумя обработчиками. Как мы делаем этот выбор? Простой вариант - разрешить route потерпеть неудачу, возвращая Maybe, (Опять же, полный Servant здесь несколько сложнее, и в версии 0.5 будет значительно улучшена стратегия маршрутизации.)

Как только мы имеем route определены, мы можем легко определить serve с точки зрения route:

serve :: HasServer layout
      => Proxy layout -> Server layout -> [String] -> IO String
serve p h xs = case route p h xs of
  Nothing -> ioError (userError "404")
  Just m  -> m

Если ни один из маршрутов не совпадает, мы терпим неудачу с 404. В противном случае мы возвращаем результат.

HasServer экземпляры

Для Get конечная точка, мы определили

type instance Server (Get a) = IO a

поэтому обработчик является действием ввода-вывода, производящим a который мы должны превратить в String, Мы используем show для этого. В реальной реализации Servant это преобразование обрабатывается механизмом типов контента и обычно включает кодирование в JSON или HTML.

instance Show a => HasServer (Get a) where
  route :: Proxy (Get a) -> IO a -> [String] -> Maybe (IO String)
  route _ handler [] = Just (show <$> handler)
  route _ _       _  = Nothing

Поскольку мы сопоставляем только конечную точку, запрос требует, чтобы в этой точке был пустой запрос. Если это не так, этот маршрут не совпадает, и мы возвращаемся Nothing,

Давайте посмотрим на выбор дальше:

instance (HasServer a, HasServer b) => HasServer (a :<|> b) where
  route :: Proxy (a :<|> b) -> (Server a :<|> Server b) -> [String] -> Maybe (IO String)
  route _ (handlera :<|> handlerb) xs =
        route (Proxy :: Proxy a) handlera xs
    <|> route (Proxy :: Proxy b) handlerb xs

Здесь мы получаем пару обработчиков, и мы используем <|> за Maybe попробовать оба.

Что происходит с литеральной строкой?

instance (KnownSymbol s, HasServer r) => HasServer ((s :: Symbol) :> r) where
  route :: Proxy (s :> r) -> Server r -> [String] -> Maybe (IO String)
  route _ handler (x : xs)
    | symbolVal (Proxy :: Proxy s) == x = route (Proxy :: Proxy r) handler xs
  route _ _       _                     = Nothing

Обработчик для s :> r того же типа, что и обработчик r, Мы требуем, чтобы запрос был не пустым, а первый компонент соответствовал аналогу уровня значения строки уровня типа. Мы получаем строку уровня значения, соответствующую строковому литералу уровня типа, применяя symbolVal, Для этого нам нужен KnownSymbol ограничение на строковый литерал уровня типа. Но все конкретные литералы в GHC автоматически являются экземплярами KnownSymbol,

Последний случай для захватов:

instance (Read a, HasServer r) => HasServer (Capture a :> r) where
  route :: Proxy (Capture a :> r) -> (a -> Server r) -> [String] -> Maybe (IO String)
  route _ handler (x : xs) = do
    a <- readMaybe x
    route (Proxy :: Proxy r) (handler a) xs
  route _ _       _        = Nothing

В этом случае мы можем предположить, что наш обработчик на самом деле является функцией, которая ожидает a, Мы требуем, чтобы первый компонент запроса был разобран как a, Здесь мы используем Read тогда как в Servant мы снова используем механизм типов контента. Если чтение не удается, мы считаем, что запрос не совпадает. В противном случае мы можем передать его обработчику и продолжить.

Тестирование всего

Теперь мы закончили.

Мы можем подтвердить, что все работает в GHCi:

GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI  ["time", "CET"]
"2015-11-01 20:25:04.594003 CET"
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI  ["time", "12"]
*** Exception: user error (404)
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI  ["date"]
"2015-11-01"
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI  []
*** Exception: user error (404)
Другие вопросы по тегам