Tutorials

Todo app

A tutorial on building a todo app that covers the basics of Okapi.


Todo app

The goal for this tutorial is to build a JSON API with the following endpoints:

  • GET /

    Health check. Returns 200 response with "OK" as the body.

  • GET /todos

    Returns all todos as JSON.

  • POST /todos

    Accepts a todo/todos encoded as JSON to store on the server.

  • GET /todos/:uid

    Returns the todo with the matching :uid as JSON.

  • PATCH /todos/:uid

    Accepts todo information as JSON. For updating a todo with the :uid.

  • DELETE /todos/:uid

    Deletes the todo with the matching :uid from the server.

Project setup

Make you sure you have the Haskell package manager, stack, installed before you continue.

To create the project folder, run the following command in your terminal:

stack new todo

This will create a directory called todo with the following contents:

- src
- package.yaml
- stack.yaml
...

Our project is going to depend on three libraries: okapi for implementing the required API endpoints, direct-sqlite for persisting/reading data, and text for handling values of the Text type.

Go to the package.yaml file in your new todo folder, and add the following lines under dependencies::

- okapi
- direct-sqlite
- text

Save and close the file, then run the stack build command to make sure your project builds.

Implementation

We're going to implement everything in Main.hs to keep it simple. The entry point to the app is going to be main :: IO ().

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module Main where

import Control.Applicative ((<|>))
import Control.Applicative.Combinators
import Control.Monad.IO.Class
import Control.Monad.Trans.Class
import Data.Aeson (ToJSON, toJSON)
import Data.ByteString.Lazy (fromStrict)
import Data.Function ((&))
import Data.Maybe (listToMaybe)
import Data.Text
import Data.Text.Encoding (encodeUtf8)
import Database.SQLite.Simple
import Database.SQLite.Simple.FromField
import Database.SQLite.Simple.ToField
import GHC.Generics (Generic, Par1)
import Okapi
import Web.FormUrlEncoded (FromForm)
import Web.HttpApiData (ToHttpApiData)
import Web.Internal.HttpApiData

-- TYPES --

data Todo = Todo
  { todoID :: Int,
    todoName :: Text,
    todoStatus :: TodoStatus
  }
  deriving (Eq, Ord, Generic, ToJSON, Show)

instance FromRow Todo where
  fromRow = Todo <$> field <*> field <*> field

data TodoForm = TodoForm
  { todoFormName :: Text,
    todoFormStatus :: TodoStatus
  }
  deriving (Eq, Ord, Generic, FromForm, Show)

instance ToRow TodoForm where
  toRow (TodoForm name status) = toRow (name, status)

data TodoStatus
  = Incomplete
  | Archived
  | Complete
  deriving (Eq, Ord, Show)

instance ToJSON TodoStatus where
  toJSON Incomplete = "incomplete"
  toJSON Archived = "archived"
  toJSON Complete = "complete"

instance FromHttpApiData TodoStatus where
  parseQueryParam "incomplete" = Right Incomplete
  parseQueryParam "archived" = Right Archived
  parseQueryParam "complete" = Right Complete
  parseQueryParam _ = Left "Incorrect format for TodoStatus value"

instance ToField TodoStatus where
  toField status =
    case status of
      Incomplete -> SQLText "incomplete"
      Archived -> SQLText "archived"
      Complete -> SQLText "complete"

instance FromField TodoStatus where
  fromField field = do
    case fieldData field of
      SQLText "incomplete" -> pure Incomplete
      SQLText "archived" -> pure Archived
      SQLText "complete" -> pure Complete
      _ -> returnError ConversionFailed field "Couldn't methodGET TodoStatus value from field"

type Okapi = OkapiT IO

-- MAIN --

main :: IO ()
main = do
  conn <- open "todo.db"
  execute_ conn "CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
  run id (todoAPI conn)
  close conn

-- SERVER FUNCTIONS

respond :: Response -> Okapi Response
respond response = do
  methodEnd
  pathEnd
  return response

todoAPI :: Connection -> Okapi Response
todoAPI conn =
  healthCheck
    <|> getTodo conn
    <|> getAllTodos conn
    <|> createTodo conn
    <|> editTodo conn
    <|> forgetTodo conn

healthCheck :: Okapi Response
healthCheck = do
  methodGET
  optional $ pathParam @Text `is` ""
  respond ok

getTodo :: Connection -> Okapi Response
getTodo conn = do
  methodGET
  pathParam @Text `is` "todos"
  todoID <- pathParam @Int
  maybeTodo <- lift $ selectTodo conn todoID
  case maybeTodo of
    Nothing -> throw internalServerError
    Just todo -> ok & setJSON todo & respond

getAllTodos :: Connection -> Okapi Response
getAllTodos conn = do
  methodGET
  pathParam @Text `is` "todos"
  status <- optional $ queryParam @TodoStatus "status"
  todos <- lift $ selectAllTodos conn status
  ok & setJSON todos & respond

createTodo :: Connection -> Okapi Response
createTodo conn = do
  methodPOST
  pathParam @Text `is` "todos"
  todoForm <- bodyForm
  lift $ insertTodoForm conn todoForm
  respond ok

editTodo :: Connection -> Okapi Response
editTodo conn = do
  methodPUT
  is @Text pathParam "todos"
  todoID <- pathParam @Int
  todoForm <- bodyForm @TodoForm
  lift $ updateTodo conn todoID todoForm
  respond ok

forgetTodo :: Connection -> Okapi Response
forgetTodo conn = do
  methodDELETE
  pathParam @Text `is` "todos"
  todoID <- pathParam @Int
  lift $ deleteTodo conn todoID
  respond ok

-- DATABASE FUNCTIONS

insertTodoForm :: Connection -> TodoForm -> IO ()
insertTodoForm conn = execute conn "INSERT INTO todos (name, status) VALUES (?, ?)"

selectTodo :: Connection -> Int -> IO (Maybe Todo)
selectTodo conn todoID = listToMaybe <$> Database.SQLite.Simple.query conn "SELECT * FROM todos WHERE id = ?" (Only todoID)

selectAllTodos :: Connection -> Maybe TodoStatus -> IO [Todo]
selectAllTodos conn maybeStatus = case maybeStatus of
  Nothing -> query_ conn "SELECT * FROM todos"
  Just status -> Database.SQLite.Simple.query conn "SELECT * FROM todos WHERE status = ?" (Only status)

updateTodo :: Connection -> Int -> TodoForm -> IO ()
updateTodo conn todoID TodoForm {..} =
  executeNamed
    conn
    "UPDATE todos SET name = :name, status = :status WHERE id = :id"
    [":id" := todoID, ":name" := todoFormName, ":status" := todoFormStatus]

deleteTodo :: Connection -> Int -> IO ()
deleteTodo conn todoID = execute conn "DELETE FROM todos WHERE id = ?" (Only todoID)
Previous
Blog app