Пишем простой RESTful сервис с использованием Scotty

7 августа 2013

Scotty — это легковесный движок для создания веб-приложений на языке Haskell. Что-то вроде Cowboy из мира Erlang или Scalatra из мира Scala. Сегодня с помощью Scotty мы прикрутим веб-интерфейс к нашей телефонной книге.

Весь код веб-приложения находится в модуле Phonebook.Interface.HTTP. Как и модуль Phonebook.Interface.CLI, он экспортирует единственную функцию:

main :: IConnection a => a -> IO ()

Посмотрим на ее код:

import qualified Web.Scotty as Sc
import qualified Phonebook.Storage as St

{- ... -}

main conn = Sc.scotty 8080 $ do
  Sc.post   "/api/v1.0/contacts"      (create conn)
  Sc.get    "/api/v1.0/contacts"      (read conn)
  Sc.put    "/api/v1.0/contacts/:cid" (update conn)
  Sc.delete "/api/v1.0/contacts/:cid" (delete conn)

Не будем заострять внимание на типах использованных здесь функций. Если очень интересно, можете заглянуть в документацию по Scotty, там все есть. Что тут происходит, понятно и без них. Scotty запускается на порту 8080. Также объявляются маршруты/роуты и соответствующие им хэндлеры.

Давайте посмотрим на функцию create:

create conn = do
  body    <- Sc.jsonData :: Sc.ActionM (M.Map String String)
  success <- withNamePhone body (\n p -> St.create n p conn)
  Sc.status $ boolToHttpCode success

Функция jsonData берет тело запроса и пытается декодировать его, как строку с JSON’ом. Здесь после декодирования мы ожидаем получить тип Map String String. Для успешного выполнения запроса пользователь должен передать JSON-объект, значения полей которого представляют собой строки. Если пользователь пошлет невалидный JSON или валидный, но такой, что его не удастся привести к ожидаемому типу, Scotty вернет ошибку 500 Internal Server Error.

Функции withNamePhone и boolToHttpCode определены так:

withNamePhone m action =
  case extractNamePhone m of
    Nothing -> return False
    Just (name, phone) -> liftIO $ action name phone

extractNamePhone :: M.Map String String -> Maybe (String, String)
extractNamePhone m =
            M.lookup "name"  m >>=
  \name  -> M.lookup "phone" m >>=
  \phone -> Just (name, phone)

boolToHttpCode success =
  if success then noContent204
             else badRequest400

Надеюсь, вы в достаточной мере владеете особой монадической магией, чтобы понять этот код, потому что обсуждение этой магии, пожалуй, представляет собой хорошую тему для целого отдельного поста. Если в двух словах, наше приложение возвращает код 204 No Content, если запись в телефонной книге была успешно создана, и 400 Bad Request, если что-то пошло не так, например, не было указано имя или телефон.

Рассмотрим следующий хэндлер, функцию read:

read conn = do
  contacts <- liftIO $ St.read conn
  let objects = map toObj contacts
  Sc.json $ Array (V.fromList objects)
  where
    toObj :: (St.ContactId, St.Name, St.Phone) -> Value
    toObj (cid, name, phone) =
      object [ ("contactId", Number $ I cid)
             , ("name",      String $ T.pack name)
             , ("phone",     String $ T.pack phone)
             ]

Здесь мы читаем из базы все имеющиеся контакты, преобразуем их в список JSON-объектов, а затем полученный список преобразуем в JSON-массив, который отдается пользователю. Для работы с JSON в Haskell большой популярностью пользуется пакет aeson. JSON в нем представляется следующим образом:

type Array = Vector Value
type Object = HashMap Text Value
data Value
  = Object !Object
  | Array !Array
  | String !Text
  | Number !Number
  | Bool !Bool
  | Null

Использованная нами функция object имеет следующий тип:

object :: [(Text, Value)] -> Value

Как вы уже догадались, она преобразует ассоциативный список в JSON-объект.

Вообще aeson — очень приятная в использовании библиотека:

ghci> :set -XOverloadedStrings
ghci> :m + Data.Aeson
ghci> import Data.ByteString.Lazy.Char8 as BSLC
ghci> BSLC.unpack $ encode $ object [("aaa", Number 123)]
"{\"aaa\":123}"
ghci> decode $ encode $ object [("aaa", Number 123)] :: Maybe (Object)
Just fromList [("aaa",Number 123)]
ghci> decode (BSLC.pack "[1,2,3]") :: Maybe [Int]
Just [1,2,3]

Помимо простого кодирования/декодирования JSON она также позволяет удобным образом писать сериализаторы и десериализаторы для произвольных типов. Подробности вы найдете в документации по пакету.

Определение функции update:

update conn = do
  cid     <- Sc.param "cid" :: Sc.ActionM Integer
  body    <- Sc.jsonData :: Sc.ActionM (M.Map String String)
  success <- withNamePhone body (\n p -> St.update cid n p conn)
  Sc.status $ boolToHttpCode success

Она очень похожа на функцию create за тем исключением, что здесь с помощью функции param мы вычленяем параметр cid из запрашиваемого URL. Интересное свойство этой функции заключается в том, что если параметр не удастся привести к ожидаемому типу, в данном случае — Integer, никакой ошибки не произойдет. Scotty просто продолжит матчить запрошенный URL с роутами далее по списку. Если ни один из оставшихся роутов не подойдет, будет возвращен код 404 Not Found.

Оставшаяся функция delete совсем простая:

delete conn = do
  cid <- Sc.param "cid" :: Sc.ActionM Integer
  success <- liftIO $ St.delete cid conn
  Sc.status $ boolToHttpCode success

Для нас здесь уже нет совершенно ничего нового. Вот и весь код! Давайте теперь посмотрим, как он работает.

Получение всех контактов из телефонной книги:

curl http://localhost:8080/api/v1.0/contacts -D -

HTTP/1.1 200 OK
Server: Warp/1.3.8.4
Transfer-Encoding: chunked
Content-Type: application/json

[{"phone":"789","name":"Alex","contactId":17}]

Создание новой записи в телефонной книге:

curl -X POST -H 'Content-type: application/javascript' \
  -d '{"name":"Bob","phone":"456"}' -D - \
  http://localhost:8080/api/v1.0/contacts

HTTP/1.1 204 No Content
Server: Warp/1.3.8.4

Редактирование записи в телефонной книге:

curl -X PUT -H 'Content-type: application/javascript' \
  -d '{"name":"Bob2","phone":"123"}' -D -
  http://localhost:8080/api/v1.0/contacts/18

HTTP/1.1 204 No Content
Server: Warp/1.3.8.4

Удаление записи:

curl -X DELETE -D - http://localhost:8080/api/v1.0/contacts/18

HTTP/1.1 204 No Content
Server: Warp/1.3.8.4

Несколько важных моментов.

Во-первых, обратите внимание, что при создании и редактировании записей мы вручную указываем Content-type. Если этого не сделать, curl пошлет заголовок Content-type: application/x-www-form-urlencoded и Scotty будет считать, что тело запроса пустое. Кстати, для отладки веб-приложений на Haskell неплохо работают curl -v, tcpdump, а также модуль Debug.Trace.

Во-вторых, здесь мы использовали одно-единственное соединение с PostgreSQL. Однако в реальных приложениях требуется создавать пул соединений и следить за тем, чтобы два процесса не воспользовались одним и тем же соединением одновременно. Также нужно корректно обрабатывать ситуации вроде падения сети между приложением и СУБД.

Дополнительные материалы:

Исходники к посту вы найдете в этом архиве.

Дополнение: Использование кондуитов (conduits) в Haskell

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.