Lucchesi
topo

API REST em Haskell usando o Scotty

O Scotty é um framework Web Haskell inspirado no Sinatra do Ruby.

Ele se destaca por ser simples, prático e usar pouca “Haskell magic”.

Neste tutorial você vai aprender a usar o Scotty para implementar uma API REST com as 4 operações básicas: create, retrieve, update e delete (CRUD).

Configurando o ambiente

Usaremos o template scotty-hello-world do Stack para inicializar nosso projeto:

stack --resolver lts-10.8 new todo-list scotty-hello-world

O comando acima criará os seguintes arquivos:

$ todo-list tree
 .
 ├── Main.hs
 ├── stack.yaml
 └── todo-list.cabal
 0 directories, 3 files

Agora vamos fazer o build da aplicação e executá-la:

stack build && stack exec todo-list

Se tudo estiver certo, sua aplicação estará disponível no endereço http://localhost:3000.

O arquivo Main.hs

Se você olhar, até então, o arquivo Main.hs tem apenas uma rota cadastrada:

get "/:word" $ do
  beam <- param "word"
  html $ mconcat ["<h1>Scotty, ", beam, " me up!</h1>"]

O funcionamento da rota /:word é fácil de entender: ela obtém a string imediatamente após o “/” na URL usando a função param; armazena o seu conteúdo na “variável” beam; concatena beam a duas outras strings; e retorna o resultado como HTML.

Experimente acessar os endereços http://localhost:3000/wake ou http://localhost:3000/lift e veja o que acontece.

Usando o GHCi

Com o servidor ainda em execução, vamos modificar a rota /:word para dizer “Olá”:

get "/:word" $ do
  name <- param "word"
  html $ mconcat ["<h1>Olá, ", word, "!</h1>"]

Se você acessar http://localhost:3000/Alexandre, você vai ver que o resultado não mudou, ou seja, o “Olá, Alexandre!” não apareceu.

Isso aconteceu porque Haskell é uma linguagem compilada e toda vez que nós mexemos no código nós precisamos recompilar e reexecutar a aplicação.

Para evitar o trabalho de matar a aplicação e executar stack build && stack exec todo-list toda vez que alterarmos o código, podemos carregar o projeto no GHCi e chamar a main diretamente:

stack ghci
main

Assim, quando mexermos no código, basta dar um Ctrl-C no GHCi, recarregar os módulos (digitando :r) e chamar a main novamente. Muito mais fácil e rápido!

Roteamento

O Scotty suporta requisições GET, POST, PUT e DELETE:

get "/" $ do
  text "Recebi um GET."

post "/" $ do
  text "Recebi um POST."

put "/" $ do
  text "Recebi um PUT."

delete "/" $ do
  text "Recebi um DELETE."

Também é possível especificar rotas coringa, que casam com qualquer método HTTP:

matchAny "/any" $ do
  text "Posso ter recebido qualquer método HTTP."

Ou escrever um handler padrão, que será usado quando nenhuma rota casar:

notFound $ do
  text "Não encontrei uma rota para sua requisição."

Além de parâmetros nomeados (como fizemos com /:word), podemos também pode especificar parâmetros não-nomeados, que são obtidos da query string (após a ?):

get "/hello" $ do
  name <- param "name"
  html $ mconcat ["<h1>Olá, ", name, "!</h1>"]

Experimente implementar a rota acima e acessar http://localhost:3000/hello?name=Alexandre.

Cabeçalhos HTTP

Podemos recuperar o conteúdo de um cabeçalho:

get "/agent" $ do
  agent <- reqHeader "User-Agent"
  text agent

Ou setar um cabeçalho:

import Network.HTTP.Types -- Adicione "http-types" como dependência no ".cabal"

get "/lucch" $ do
  status status302 -- Redireciona permanentemente para "Location"
  header "Location" "https://lucchesi.com.br/"

Content Types

Podemos retornar respostas em HTML, plain text ou JSON:

html "Hello, world!"
text "Hello, world!"
json ("Hello, world!" :: String)
json ([0..10] :: [Int])

Observe que precisamos ser explícitos com os tipos quando usamos JSON.

Criando o “Model”

Neste tutorial, criaremos um app de lista de tarefas. A única regra é que as tarefas devem ser agrupadas por dia e priorizadas.

Assim, podemos usar o seguinte modelo de dados:

Traduzindo para Haskell:

import Data.Map.Strict (Map) -- Adicione "containers" como dependência no ".cabal"
import qualified Data.Map.Strict as Map

type Day = String

type Task = String

allTasks :: Map Day [Task]
allTasks = Map.fromList
  [ ( "2018-01-01"
    , [ "Lavar a louça" -- Tarefa de maior prioridade
      , "Pagar o contador"
      , "Fazer compras"
      , "Estudar Haskell"
      , "Ligar para o cliente"
      , "Ler um livro" -- Tarefa de menor prioridade
      ]
    )
  , ( "2018-01-02"
    , [ "Escrever um tutorial"
      , "Ligar para o Vinícius"
      , "Comprar um buquê"
      , "Tirar férias"
      , "Ir ao cinema"
      , "Visitar parentes"
      ]
    )
  , ( "2018-01-03"
    , [ "Ir a Igreja"
      , "Abastecer o carro"
      , "Varrer a casa"
      , "Tomar banho"
      , "Salvar o planeta"
      , "Estudar Haskell"
      ]
    )
  ]

API REST para consulta (GET)

Com apenas duas linhas de código, podemos criar um endpoint REST que retorna um JSON com as tarefas em allTasks:

get "/tasks" $ do
  json allTasks

E com mais três, outro endpoint que retorna as tarefas de um dia específico:

-- Ex: GET /tasks/2018-01-01
get "/tasks/:day" $ do
  day <- param "day"
  json $ M.lookup day allTasks

Na próxima seção implementaremos APIs de atualização, que precisarão modificar o conteúdo de allTasks, que é a variável que estamos usando para simular o nosso banco de dados. Contudo, da maneira como está, allTasks é uma constante.

Antes de continuar, precisamos atualizar o código para transformar allTasks em uma variável global mutável. Podemos fazer isso encapsulando allTasks em uma MVar, que possui as seguintes operações:

newMVar :: a -> IO (MVar a)                      -- Cria uma nova variável.
readMVar :: MVar a -> IO a                       -- Lê o conteúdo da variável.
modifyMVar_ :: MVar a -> (a -> IO a) -> IO ()    -- Modifica a variável.
modifyMVar :: MVar a -> (a -> IO (a, b)) -> IO b -- Modifica e retorna um valor.

O código da API de consulta atualizado para usar MVar fica assim:

main :: IO ()
main = do
  -- Cria e inicializa uma MVar tasks' com allTasks
  tasks' <- newMVar allTasks

  scotty 3000 $ do
    get "/tasks" $ do
      -- Lê o conteúdo em tasks' para tasks
      tasks <- liftIO $ readMVar tasks'
      json tasks

    get "/tasks/:day" $ do
      tasks <- liftIO $ readMVar tasks'
      day <- param "day"
      -- Consulta tasks usando day como chave
      json $ M.lookup day tasks

API REST para Atualização (POST, PUT, DELETE)

Seguindo os princípios do REST, implementaremos os seguintes endpoints:

-- Cadastra novas tarefas no dia especificado.
post "/tasks/:day" $ do
  undefined

-- Atualiza a lista de tarefas do dia especificado.
put "/tasks/:day" $ do
  undefined

-- Exclui todas as tarefas do dia especificado.
delete "/tasks/:day" $ do
  undefined

Vamos fazer juntos o post /tasks/:day e os outros ficarão de dever de casa. 😁

validateTasks :: [Task] -> Bool
validateTasks tasks =
  length tasks == 6 -- 6 tarefas por dia parece razoável.

validateDay :: Day -> Bool
validateDay = ...

post "tasks/:day" $ do
  day <- param "day"
  -- Faz o "parsing" da string JSON no corpo do POST para uma lista.
  newTasks <- jsonData
  if not (validateDay day && validateTasks newTasks)
    then status status400 -- Bad request
    else do
      created <- liftIO $ modifyMVar tasks' $ \tasks ->
        if M.member day tasks -- Este dia já está cadastrado?
          then return (tasks, False)
          else return (M.insert day newTasks tasks, True)
      if created
        then status status200 -- OK
        else status status403 -- Forbidden

Essa função traz algumas novidades. A primeira é a função jsonData, que faz o parsing da string JSON no corpo da requisição POST para um tipo de dados Haskell (no nosso caso, uma lista). A segunda são as funções validateDay e validateTasks, que são simples predicados para validar os dados recebidos. Por fim, temos a função modifyMVar, que recebe uma MVar, uma função para ser aplicada ao conteúdo da MVar (nosso Map Day [Task]) e retorna um Bool indicando se uma nova lista de tarefas foi ou não criada. Em seguida, usamos esse booleano para retornar o código HTTP apropriado.

Agora que você já viu o post /tasks/:day, como você implementaria o put /tasks/:day? E o delete /tasks/:day? (Dica: veja as funções update e delete de Map).

Conclusão

Neste tutorial nós aprendemos a usar o Scotty para construir APIs REST em Haskell. Aprendemos como receber dados no formato JSON, tratar requisições HTTP de diferentes métodos e a retornar uma resposta para o cliente.

Ainda temos uma longa estrada até implementarmos tudo que uma aplicação real requer, por exemplo, precisamos armazenar os dados em um banco de dados de verdade, gerar documentação automaticamente, executar testes automatizados, etc.

Espero trazer todos esses conteúdos em posts futuros!