Раздел 21 · Haskell

Servant: API как тип

senior~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Servant: API как тип

Домен готов. Теперь поднимем над ним HTTP. Servant идёт против привычного: маршрут это не строка в роутере и не декоратор над функцией, а тип. Из одного типа API библиотека выводит и сервер (он обязан реализовать ровно эту форму, иначе не скомпилируется), и клиент (типобезопасные функции бесплатно). Весь type-level Haskell из урока 40 тут окупается разом.

Идея: маршрут это тип

В большинстве фреймворков API живёт в рантайме: ты регистрируешь обработчики строками путей, а соответствие пути и функции проверяется в лучшем случае тестами. Servant переносит описание API на уровень типов. Маршрут собирается из комбинаторов, каждый комбинатор это тип, а склеиваются они операторами :> (путь дальше) и :<|> (или такой маршрут, или такой).

Вот открытая часть нашего API: проверка живости и чтение каталога игр.

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}

module Meeple.Api where

import Servant.API

type PublicApi =
  "health" :> Get '[JSON] Health
    :<|> "games" :> Get '[JSON] [Game]
    :<|> "games" :> Capture "gameId" Int64 :> Get '[JSON] Game

Прочитай это вслух как ТЗ. "health" :> Get '[JSON] Health: по пути /health на GET отдаём Health в виде JSON. "games" :> Capture "gameId" Int64 :> Get '[JSON] Game: по /games/{gameId}, где сегмент это Int64, отдаём одну игру. Три маршрута склеены :<|> в один тип.

Комбинаторы

Каждый кусок маршрута это комбинатор-тип:

  • "games" статический сегмент пути.
  • Capture динамический сегмент: кусок пути, разобранный в значение.
  • QueryParam параметр строки запроса (?minPlayers=3).
  • ReqBody тело запроса (для POST).
  • Get, Post закрывают маршрут: метод, формат и тип ответа.

Управляющую часть (всё, что меняет состояние) выделим отдельным типом, чтобы в уроке 46 накрыть её авторизацией одним движением:

type ManageApi =
  "games" :> ReqBody '[JSON] NewGame :> Post '[JSON] Game
    :<|> "members" :> ReqBody '[JSON] NewMember :> Post '[JSON] Member
    :<|> "copies" :> Capture "copyId" Int64 :> "lend"
        :> ReqBody '[JSON] LendRequest :> Post '[JSON] Copy
    :<|> "copies" :> Capture "copyId" Int64 :> "return" :> Post '[JSON] Copy

NewGame это отдельный тип, не Game: у новой игры ещё нет идентификатора, его выдаст база. Это снова parse, don’t validate на входе, отдельная форма для запроса и для сущности.

data NewGame = NewGame
  { newGameTitle :: Text
  , newGameMinPlayers :: Int
  , newGameMaxPlayers :: Int
  }
  deriving stock (Generic)
  deriving anyclass (FromJSON, ToJSON)

Что обслуживает эту магию

Откуда Servant знает, как превратить тип "games" :> Get '[JSON] [Game] в реальный обработчик? Через type-level механику из урока 40. Класс HasServer определён индукцией по структуре типа API: он спускается вниз по типу так же, как ты спускаешься по дереву вызовов в рекурсивной функции. Для a :> b рекурсивно разбирает оба куска, для Get упирается в обработчик, для Capture добавляет аргумент. Type family ServerT вычисляет по типу API тип сервера, то есть форму, которую обязаны иметь твои обработчики.

Тебе не нужно писать инстансы HasServer, они уже есть для всех комбинаторов. Важно понять одно: соответствие между API и обработчиками проверяет компилятор. Забудешь обработчик, перепутаешь тип ответа, поменяешь порядок аргументов после Capture, и код не соберётся.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.