Fabián Vega Alcota
Posted on July 12, 2022
This is an example of REST API built with Scotty a web framework of Haskell and PostgreSQL a relational database. It's a simple API to manage products.
Index
Running PostgreSQL
This tutorial tries to be as simple to understand as possible, but if you know how to use docker-compose and use a PostgreSQL database, you can skip this section.
The first step is make a docker-compose.yml
file.
version: "3"
services:
database:
build: ./db
container_name: production-db
ports:
- "5432:5432"
env_file:
- example.env
volumes:
- ./db/postgres:/var/lib/postgresql/data
The example.env
file is a file with the following content:
POSTGRES_USER=postgres
POSTGRES_PASSWORD=123456
POSTGRES_DB=products
This file is used to configure the credentials of database.
In docker-compose.yml
don't use a specific images of PostgreSQL, because it is declared in Dockerfile
this way:
FROM postgres:alpine
ADD scripts/init.sql /docker-entrypoint-initdb.d
RUN chmod a+r /docker-entrypoint-initdb.d/*
EXPOSE 6666
Thus, the database is initialized with the file init.sql
, only when the db is created. The file init.sql
is a file with the following content:
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
price INTEGER NOT NULL,
description TEXT
);
INSERT INTO products (name, price, description) VALUES
('Product 1', 10, 'Description 1'),
('Product 2', 20, 'Description 2'),
('Product 3', 30, 'Description 3');
These queries create the table products and insert some products.
In this way the database is available in the port 5432 and the data is stored into db.
JSON REST API
The first part of the API is a JSON REST API, with the following endpoints:
Method | Route | Description |
---|---|---|
Get | http://localhost:8080/api/product/ | Retrieve all products |
Get | http://localhost:8080/api/product/:id | Retrieve a specific product |
Post | http://localhost:8080/api/product/ | Add a product |
Put | http://localhost:8080/api/product/:id | Update a product |
Delete | http://localhost:8080/api/product/:id | Delete a product |
Declaring the routes of the API
The routes are declaring in the file Main.hs
, but before, as it is common in Haskell, it is necessary to build a main
function where the application is executing. Here where the connection to the database is also creating:
import Database.PostgreSQL.Simple
localPG :: ConnectInfo
localPG =
defaultConnectInfo
{ connectHost = "0.0.0.0",
connectDatabase = "products",
connectUser = "postgres",
connectPassword = "123456"
}
main :: IO ()
main = do
conn <- connect localPG
{- continue -}
Then, for the routes, you can create a function with the routes to separate these processes.
import Lib
import Web.Scotty (delete, get, post, put, scotty)
routes :: Connection -> IO ()
routes conn = scotty 8080 $ do
get "/api/product/" $ getProducts conn
get "/api/product/:id" $ getProduct conn
post "/api/product/" $ createProduct conn
put "/api/product/:id" $ updateProduct conn
delete "/api/product/:id" $ deleteProduct conn
This is called from main
:
main :: IO ()
main = do
conn <- connect localPG
routes conn
The product data type
Before creating the functions to get the products and use it, you must create the product data type.
data Product = Product
{ idProduct :: Int,
name :: String,
price :: Int,
description :: String
}
This allows to receive the data from the database and manage it in a better way, but defining only the data type is not enough for Haskell to parse the data from the database to the Product
type. To do this, we need to instance from the FromRow
type class and define how the data is translated to the type we define.
Yes, Haskell also has a class, but it is not a class as in the OOP paradigm. It can be considered as a kind of types. For more information see the following explanation from Learn You a Haskell for Great Good!.
import Database.PostgreSQL.Simple.FromRow
instance FromRow Product where
fromRow = Product <$> field <*> field <*> field <*> field
The operators
<*>
and<$>
are explained in here
Now it only remains to do the same to be able to serialize the type Product
to a JSON and vice versa. That is possible instancing from the type class FromJSON and ToJSON, in this way:
import Data.Aeson
instance FromJSON Product where
parseJSON (Object o) =
Product <$> o .:? "id" .!= 0
<*> o .: "name"
<*> o .: "price"
<*> o .: "description"
parseJSON _ = fail "Expected an object for Product"
This mean that now we have defined the function parseJSON
, this receives a JSON and when this JSON is an object, it will look for the fields id
, name
, price
and description
. For that, we use the .:
and .:?
operators, which means get field and maybe get field, correspondingly. And the operator .!=
is used to set the default value for the field when it is not found. For example, when you receive a JSON object (called simplyo
) {"name": "foo"}
and you get the field "id"
using o .:? "id"
in this case you get a Nothing
, but if next to it, you use .!= 0
then the result will be 0
for the id
.
Resume about how to use the Functors and Applicatives
Haskell take advantage of the property of the Functors and Applicatives type classes.
The Functor type class has a function calledfmap
that allows to map a function over a functor, also can be used with the operator<$>
.
The Applicatives is like a "subclass" of the Functor type class, and has a function called<*>
that allows to apply a function wraped into a functor to a other functor.
So, when we use<$>
and<*>
means, take the constructor of typeProduct
and apply it to the result to get a field. We still need more fields to have aProduct
type, so now it is a function wrapped by a functor. To apply the wrapped function, we use<*>
and so on until we construct theProduct
type with all its fields.
And similarly to convert a Product
type to a JSON:
import Data.Aeson
instance ToJSON Product where
toJSON (Product idProduct name price description) =
object
[ "id" .= idProduct,
"name" .= name,
"price" .= price,
"description" .= description
]
The
object
function is used to create a JSON object, and the.=
assigns a value to a field in the object.
Get products
The function getProducts
is a function that response a list of products.
Why have I said that the function response a list of products and not return a list of products?
That's because the function truly return aActionM ()
, this is similar toIO
monad and it is way to produce side effects.
import Control.Monad.IO.Class (liftIO)
import Database.PostgreSQL.Simple
import Web.Scotty (ActionM)
import qualified Web.Scotty as S
getProducts :: Connection -> ActionM ()
getProducts conn = do
products <- (liftIO $ query_ conn "SELECT * FROM products") :: ActionM [Product]
S.json $ object ["products" .= products]
line 6 | This function receives a connection to the database and returns an empty ActionM , this is very similar to a IO monad, but in simple words, it provides a response to the client. |
---|
This is the way that Haskell control the side effects for maintain the referential transparency since Haskell is a pure functional language.
line 7 | Save connection into conn . |
---|
line 8 | This executes the query to get all the saved products. The query_ function is very similar to query the difference is that this one does not receive values to replace in the query. Also, the result of this query is interpreting as an IO [Product] type and liftIO is a function that take a IO a and transform to m a value, where m is another Monad. That is, it transforms IO [Product] result of query_ into ActionM [Product] , that is why the :: operator is using here, to give information to Haskell about final type. |
---|
line 9 | This return a json with an object that into have "products" with a list of products. |
---|
So, if you query the route http://localhost:8080/api/product/
you get something like this:
{
"products": [
{
"description": "Description 1",
"id": 1,
"name": "Product 1",
"price": 10
},
{
"description": "Description 2",
"id": 2,
"name": "Product 2",
"price": 20
},
{
"description": "Description 3",
"id": 3,
"name": "Product 3",
"price": 30
}
]
}
Get one product
The function getProduct
is a function that retrieve a id product and response a product.
import Data.Aeson
import Database.PostgreSQL.Simple
import Network.HTTP.Types.Status (status200, status400)
import Web.Scotty (ActionM, param, status)
getProduct :: Connection -> ActionM ()
getProduct conn = do
_idProduct <- param "id" :: ActionM Int
let result = query conn "SELECT * FROM products WHERE id = ?" (Only _idProduct)
product <- liftIO result :: ActionM [Product]
case product of
[] -> do
status status400
S.json $ object ["error" .= ("Product not found" :: String)]
_ -> do
status status200
S.json (head product)
line 2 | This function is similar to the getProducts function, but it receives the identifier of some product, for this the param function is using, and the result is setting as an ActionM Int type. |
---|
line 3 | Here we use the query function, it can format any query using values wrapped with Only . Thus, we get a custom query by adding '?' where a field should be added. |
---|
line 5 - line 11 | Depending on the result of the query, it is possible to obtain an empty list or a list with at least one value, and the best way to declare this is with pattern matching. If the list is empty the result is a status code 400 and a message of error, else if the list have at least one value return a status code 200 and the first value of the list. |
---|
Add a product
The function createProduct
is very similar to the previous functions. A different thing is that here is used the execute
function and not query
, which is used to execute a query and return the number of rows affected. This result is used to determine if the product was created or not. Other different is that now we use the jsonData
function to get the JSON data from the request, for this work we created the intances
of the FromJSON
typeclass.
import Data.Aeson
import Database.PostgreSQL.Simple
import Network.HTTP.Types.Status (status201, status400)
import Web.Scotty (ActionM, param, status)
createProduct :: Connection -> ActionM ()
createProduct conn = do
(Product _ _name _price _description) <- jsonData
let result =
execute
conn
"INSERT INTO products (name, price, description) VALUES (?, ?, ?)"
(_name, _price, _description)
n <- liftIO result
if n > 0
then do
status status201
S.json $ object ["message" .= ("Product created" :: String)]
else do
status status400
S.json $ object ["error" .= ("Product not created" :: String)]
Update and Delete a product
And so on, the function updateProduct
and deleteProduct
are similar to the previous functions, but for do more easy to know if the product exists or not, we going to create the function hasProduct
to check if the product exists.
import Data.Aeson
import Database.PostgreSQL.Simple
import Network.HTTP.Types.Status (status200, status204)
import Web.Scotty (ActionM, jsonData, param, status)
updateProduct :: Connection -> ActionM ()
updateProduct conn = do
_idProduct <- param "id" :: ActionM Int
(Product _ _name _price _description) <- jsonData :: ActionM Product
productExist <- liftIO $ haveProduct conn _idProduct
if not productExist
then createProduct conn
else do
let result =
execute
conn
"UPDATE products SET name = ?, price = ?, description = ? WHERE id = ?"
(_name, _price, _description, _idProduct)
n <- liftIO result
status $ if n > 0 then status200 else status204
S.json $ object ["message" .= ("Product updated" :: String)]
deleteProduct :: Connection -> ActionM ()
deleteProduct conn = do
_idProduct <- param "id" :: ActionM Int
productExist <- liftIO $ haveProduct conn _idProduct
if not productExist
then do
status status400
S.json $ object ["error" .= ("Product not found" :: String)]
else do
status status200
liftIO $ execute conn "DELETE FROM products WHERE id = ?" (Only _idProduct)
S.json $ object ["message" .= ("Product deleted" :: String)]
Check if the product exists
import Database.PostgreSQL.Simple
haveProduct :: Connection -> Int -> IO Bool
haveProduct conn _idProduct = do
[Only n] <- query conn "SELECT COUNT(*) FROM products WHERE id = ?" (Only _idProduct) :: IO [Only Int]
return $ n > 0
Quick run instructions
Before to start, you need to have stack and docker-compose installed.
For running the API, you need to run the database using docker-compose, first is necessary to build the images and later create and start the containers with next commands:
$ docker-compose build
$ docker-compose up
Once start the database, install dependencies and run the API:
$ stack install
$ stack run
What is next?
This tutorial try to explain how you can create a REST API using Haskell and use a database to store the data. This is a first part of the tutorial, and the next step is to manage HTML pages, for that we continue using Scotty and added the library Blaze.
The project can be found here and if you have any question, please contact me on Twitter or put a comment here.
Posted on July 12, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.