← На главную

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

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