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.