JSON example: weather forecast

We have learned all we need to know about mig to be able to build something cool with it. Let's build a weather forecast application. The app has registered users which can request authorization tokens. With that token users can request for weather in specific city and on specific time and also they can update the weather data. For simplicity we omit user registration and defining roles for the users.

Domain for our application

Let's define main types for our application in the module Types.hs. We will import Mig.Json.IO to bring in the scope some classes and types common for HTTP-servers:

module Types where

import Data.Time (Day)
import Mig.Json.IO

Domain of users

There are users in the application that can register and get session tokens:

data User = User
  { name :: Text
  , pass :: Text
  }
  deriving (Generic, ToJSON, FromJSON, ToSchema)

newtype AuthToken = AuthToken Text
  deriving newtype 
    (ToJSON, FromJSON, FromHttpApiData, Eq, Ord, Show, ToParamSchema, ToSchema)

We need the instances to pass the data over HTTP wires.

Domain of weather

We are going to query weather info by location and date:

newtype DayInterval = DayInterval Int
  deriving newtype (ToJSON, FromJSON, FromHttpApiData, ToParamSchema)

data Timed a = Timed
  { from :: Day
  , content :: [a]
  }
  deriving (Generic, ToJSON, FromJSON)

deriving instance (ToSchema a) => ToSchema (Timed a)

newtype Location = Location Text
  deriving newtype 
    (ToJSON, FromJSON, FromHttpApiData, Eq, Ord, Show, ToParamSchema, ToSchema)

The weather has information on temperature, speed of the wind, sun/rain ratio and pressure:

data WeatherData = WeatherData
  { temperature :: Int
  , windSpeed :: Int
  , sunRainRatio :: Int
  , pressure :: Int
  }
  deriving (Generic, ToJSON, FromJSON, ToSchema)

Also some users can update DB of weather:

-- | Update weather data
data UpdateData = UpdateData
  { day :: Day
  , location :: Location
  , content :: WeatherData
  }
  deriving (Generic, ToJSON, FromJSON, ToSchema)

That is our domain for the weather application.

Lets define a server

We are going to build JSON HTTP application. For that we will use the module Mig.Json.IO which provides handy types specified to our domain. We expect our application to have shared context Env which we pass to all handlers.

import Mig.Json.IO
import Types

server :: Env -> Server IO
server env =
  withSwagger def $
    "api/v1/weather"
      /. [ auth
         , withAuth env $: app
         ]
  where
    auth = "get/auth-token" /. requestAuthToken env

    app =
      mconcat
        [ "get/weather" /. getWeather env
        , "update" /. updateWeather env
        ]

-- | Site internal shared context
data Env = Env

-- weather

getWeather ::
  Env ->
  Capture "location" Location ->
  Capture "day" Day ->
  Capture "day-interval" DayInterval ->
  Get (RespOr Text (Timed WeatherData))
getWeather = undefined

updateWeather ::
  Env ->
  Body UpdateData ->
  Post (RespOr Text ())
updateWeather = undefined

-- users

requestAuthToken :: Env -> Body User -> Post (RespOr Text AuthToken)
requestAuthToken = undefined

withAuth :: Env -> Header "auth" AuthToken -> Plugin IO
withAuth = undefined

We have one route to query for token:

requestAuthToken :: Env -> Body User -> Post (RespOr Text AuthToken)

And two routes that query info on weather and update it:

getWeather ::
  Env ->
  Capture "location" Location ->
  Capture "day" Day ->
  Capture "day-interval" DayInterval ->
  Get (RespOr Text (Timed WeatherData))

updateWeather ::
  Env ->
  Body UpdateData ->
  Post (RespOr Text ())

Also we have a plugin that filters out non authorized calls:

withAuth :: Env -> Header "auth" AuthToken -> Plugin IO

From its type-signature we can assume that authorization token is passed in the header of the request.

The structure of the server

We define server as a collection of actions that can be performed. The server is defined in terms of interfaces. We can initialize those interfaces and pass them to handlers.

Our app has several domains:

  • users and sessions

  • weather DB interface

  • process life cycle: logging, startup, cleanup, etc

So the server environment has three parts:

data Env = Env
  { auth :: Auth
  , weather :: Weather
  , proc :: Proc
  }

Let's define operations for those domains. We define them in the module Interface.hs.

User domain

For the user we can do

  • check that user is valid and can use the app

  • allocate new authorization token

  • check that token is valid

  • expire the token (make it invalid)

-- authorization interface
data Auth = Auth
  { newToken :: User -> IO AuthToken
  , validUser :: User -> IO Bool
  , validToken :: AuthToken -> IO Bool
  , expireToken :: AuthToken -> IO ()
  }

Weather domain

For the weather we can query info and update it:

-- weather forecast interface
data Weather = Weather
  { get :: Location -> Day -> DayInterval -> IO (Maybe (Timed WeatherData))
  , update :: UpdateData -> IO ()
  }

Process domain

For the application process we keep all server life cycle tools which are not related to business logic domain. It can be logging, metrics, startup and cleanup actions:

-- | Process interface
data Proc = Proc
  { startup :: IO ()
  , cleanup :: IO ()
  , Logger :: Logger
  }

-- logger interface
data Logger = Logger
  { info :: LogFun
  , debug :: LogFun
  , error :: LogFun
  }

type LogFun = Value -> IO ()

We log JSON-values. As a helper functions we create functions that can log anything which is convertible to JSON:

logInfo :: (ToJSON a) => Env -> a -> IO ()
logInfo env = env.proc.logger.info . toJSON

logDebug :: (ToJSON a) => Env -> a -> IO ()
logDebug env = env.proc.logger.debug . toJSON

logError :: (ToJSON a) => Env -> a -> IO ()
logError env = env.proc.logger.error . toJSON

Using interfaces

It's interesting to note how all actions on shared state can be expressed as interfaces. We will declare the concrete mutable representation later but for now it is ok to hide them with not implemented yet functions. This also allows us to create mock applications for testing and substitute implementations without changing the code for the server.

Define server in terms of interfaces

As we have defined the main operations of the application we can complete the definition of the server. Let's define the routes for weather domain first as they are more simple.

Weather domain

We can query the weather forecast with function:

getWeather ::
  Env ->
  Capture "location" Location ->
  Capture "day" Day ->
  Capture "day-interval" DayInterval ->
  Get (RespOr Text (Timed WeatherData))
getWeather env (Capture location) (Capture fromDay) (Capture interval) = Send $ do
  logInfo @Text env "get the weather forecast"
  mResult <- env.weather.get location fromDay interval
  pure $ case mResult of
    Just result -> ok result
    Nothing -> bad status400 "No data"

We log that call to get weather is in the progress. Then we try to fetch weather data and if it has the data we return it to the user otherwise we report error.

Let's update the weather data:

updateWeather ::
  Env ->
  Body UpdateData ->
  Post (Resp ())
updateWeather env (Body updateData) = Send $ do
  logInfo @Text env "update the weather data"
  ok <$> env.weather.update updateData

We log the report and update the weather data.

The user domain

Let's give the user access token and check that token is valid. Let's allocate a new token in the handler requestAuthToken:

requestAuthToken :: Env -> Body User -> Post (RespOr Text AuthToken)
requestAuthToken env (Body user) = Send $ do
  logInfo env ("get new auth token for: " <> user.name)
  isValid <- env.auth.validUser user
  if isValid
    then do
      token <- env.auth.newToken user
      void $ forkIO $ setExpireTimer token
      pure $ ok token
    else do
      logError env $ Text.unwords ["User", user.name, "does not have access to the service"]
      pure $ bad unauthorized401 "User is not valid"
  where
    setExpireTimer :: AuthToken -> IO ()
    setExpireTimer token = do
      threadDelay (1_000_000 * 60 * 10) -- 10 minutes
      env.auth.expireToken token

We check that user is valid and if the user is valid we give user a token and also set the expiration for it. We will expire it 10 minutes after registration. The expiration is concurrent process that is forked from the thread that handles the request. If user has no rights to use our service we report error.

Let's check for authorization tokens. Ideally we would like to add this action to all handlers of our application. We would like to keep the business logic handlers for the weather domain the same. And we can do it with plugin. Let's define such a plugin that expects authorization tokens with required header:

withAuth :: Env -> Header "auth" AuthToken -> Plugin IO
withAuth env (Header token) = processResponse $ \getResp -> do
  isOk <- env.auth.validToken token
  if isOk
    then getResp
    else do
      logError env errMessage
      pure $ Just (bad status500 $ Text.encodeUtf8 errMessage)
  where
    errMessage = "Token is invalid"

We have covered in depth how to implement it in the chapter on Plugins so this code should look familiar to us.

Run application

That completes the definition of the server. Let's run it. We define the main function in the module Main.hs:

main :: IO ()
main = do
  env <- initEnv port
  env.proc.startup
  runServer port (server env)
    `finally` env.proc.cleanup
  where
    port = 8085

initEnv :: Port -> IO Env
initEnv = undefined

The initialization of interfaces is yet to be defined. So this is all we need to start the server.

Implementation of the interfaces

For the purpose of the example we will create a mock application. A bit more detailed implementation is in the source code of the mig library. See example JsonApi.

Mock application

We can create a mock application with one user.

import Data.ByteString.Char8 qualified as B
import Data.Yaml qualified as Yaml
import Data.Aeson qualified as Json

initEnv :: Port -> IO Env
initEnv port = pure $ Env initAuth initWeather (initProc proc)

-- | Application with single user john with token that never expires
initAuth :: Auth
initAuth = 
  Auth
  { newToken = const $ pure AuthToken "john-token"
  , validUser = \(User name pass) = pure $ name == "john" && pass == "123"
  , validToken = (\AuthToken token) -> pure (token == "john-token")
  , expireToken = const $ pure ()
  }

initWeather = Weather
  { get = \location day dayInterval -> pure Nothing
  , update = \updateData -> pure ()
  }

initProc :: Port -> Proc
initProc = 
  pure Proc 
    { logger = logger
    , startup = logger.info $ "App started on port: " <> Text.pack (show port)
    , cleanup = logger.info "App shutdown"
    }
  where
    logger = initLogger

initLogger :: Logger
initLogger = Logger
  { info = logBy "info" 
  , debug = logBy "debug"
  , error = logBy "error"
  }
  where
    logBy :: Text -> Json.Value msg -> IO ()
    logBy level msg = B.putStrLn . Yaml.encode . addLogPrefix $
        Json.object [ "level" .= level, "message" .= msg ]

addLogPrefix :: Json.Value -> Json.Value
addLogPrefix val = Json.object ["log" .= val]

We can start the application and try it out with swagger.

Exercises

You can find the complete code of the example in the mig repo.

  • implement routes for user registration. Only registered users can receive the authorization token.

  • implement roles for the users:

    • admin: can manage users
    • db-writer: can update weather forecast
    • visitor: can only view forecasts
  • implement in-memory storage of the weather DB. Use maps to store weather data and info on valid users and tokens.

  • implement real logger with fast-logger library

  • implement interface that connects application to some real DB. The code for the server should stay the same and only initialization of interface should change. Use one of the DB libraries for Haskell: hasql, postgresql-simple

Summary

In this chapter we have defined a more substantial example of JSON HTTP application and applied various concepts in practice.