Making a Haskell (Scotty) web app and putting it on Heroku

  • 9
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

This won't be a very in-depth tutorial or anything, but should serve to provide an updated reference for how to make a simple Haskell web app using Scotty that connects to Redis and serves out some static assets (in this case, my Elm app).

This project uses stack and requires little more than haskell-stack on homebrew and npm.

Config

Haskell

Once the stack project init has run, you'll have to add some dependencies to [your-proj].cabal:

build-depends:       base
                   , scotty <- scotty and some deps
                   , clay
                   , text
                   , blaze-html <- blaze for html
                   , blaze-markup
                   , wai-extra <- stuff for the web app interface
                   , wai-middleware-static
                   , hedis <- redis lib for haskell
                   , directory <- system directory info stuff
                   , regex-base <- regex packages
                   , regex-tdfa
                   , regex-compat-tdfa
                   , bytestring <- because many libs use bytestrings
                   , transformers <- monad transformers, e.g. liftIO
                   , aeson <- aeson for JSON

In addition, I've found I've had to declare my other modules like so: (please correct me if I've messed it up)

other-modules:       Database
                   , Routes
                   , Views.Index
                   , Views.Layout

Web

I also have a front-end project within in a directory called "web". Probably the config can live in your main project folder, I just made it this way out of laziness.

web/
  src/
    Main.elm
    index.js
  package.json
  webpack.config.js
in project.json:
  "devDependencies": {
    "elm-webpack-loader": "^3.0.3",
    "webpack": "^1.13.1"
  },

webpack.config.js:
  module.exports = {
    entry: [
      './src/index.js'
    ],
    output: {
      filename: 'dist/index.js'
    },
    module: {
      loaders: [{
        test: /\.elm$/,
        exclude: [/elm-stuff/, /node_modules/],
        loader: 'elm-webpack'
      }]
    }
  };

We'll come back to Heroku set up at the very end.

Code

Haskell

I don't necessarily want to go into everything, but I'll try to have the important bits included.

Main:

import Database (connectInfo, getDBConnection, seedDB)
import Routes (routes)

main :: IO ()
main = do
  -- get the list of my show files, extract the show name, and then remove duplicates
  myShows <- nub . getNames <$> getShows

  -- read in environment vars
  port <- read <$> getEnv "PORT"
  host <- getEnv "REDIS_HOST"
  redisPort <- getEnv "REDIS_PORT"
  auth <- getEnv "REDIS_AUTH"

  -- get a connection to my DB using my own connectInfo constructor helper
  -- also make sure my auth key has been packed into a bytestring
  conn <- getDBConnection $ connectInfo host redisPort $ pack auth

  -- run the seeding of my DB
  _ <- seedDB conn myShows

  -- start up scotty now
  scotty port $ do
    -- apply static asset delivery using the provided path
    middleware $ staticPolicy $ addBase "web/dist"

    -- apply my routes function
    routes conn myShows

Routes:

routes :: Connection -> [String] -> ScottyM ()
routes conn myShows = do
  get "/" $ do
    -- lift the IO operation from the standard ScottyM action
    -- where I'm encoding data I get and showing it as a String
    -- mapped to the result of fetching my shows data
    myShows' <- liftIO $ show . encode <$> fetchShowsData conn myShows

    -- build html with blaze using that string input
    indexView myShows'

  post "/increment" $ do
    -- get "name" from the form data params
    name <- param "name"

    -- do an incrementShow operation
    result <- liftIO $ incrementShow conn name

    -- handle the result
    case result of
      -- when it works, we can build html packing the string showing of the result
      Right int -> html $ pack $ show int

      -- very lazy error case handling
      _ -> html "u suck"

Database:

-- declare the MyShow type and its fields
data MyShow = MyShow
  { name :: String
  , count :: String
  }

-- then instance Data.Aeson.ToJSON accordingly for how to serialize MyShow
-- (this can be generically derived though, I did it for verbosity)
instance ToJSON MyShow where
  toJSON (MyShow name' count') = object ["name" .= name', "count" .= count']

-- fetchShowsData looks like this: (it's not actually *as* shitty as it looks at first)
fetchShowsData :: Connection -> [String] -> IO [MyShow]
fetchShowsData conn xs =
  -- map my extract method to the inner functor (my list of shows)
  -- and map that to the result of getting each show from the list xs
  -- traverse lets me do all the IO operations and return the result
  (<$>) extract <$> getShow `traverse` xs

  where
    -- run the redis command using the connection
    -- and map the result with a tuple of (x, result)
    -- using the command GET with my key prefixed appropriately
    getShow x = runRedis conn $ (x,) <$> get (prefix x)

    -- extract the tuple information to a MyShow
    extract (x, y) =
      MyShow
        { name = x
        , count = case y of
            Right (Just a) -> unpack a
            _ -> "N/A"
        }

-- updateShow (and inc, dec) are much simpler:
data UpdateShow = INCREMENT | DECREMENT

updateShow :: UpdateShow -> Connection -> String -> IO (Either Reply Integer)
updateShow update conn =
  -- run the command specified and prefix the provided key accordingly
  runRedis conn . command . prefix

  where
    -- the ADT argument breaks down to two operations we support here
    command = case update of
      INCREMENT -> incr
      DECREMENT -> decr

-- create an incrementing updateShow function
incrementShow = updateShow INCREMENT

-- vice versa
decrementShow = updateShow DECREMENT

Views.Index:

injectScript :: String -> Html
injectScript = script . toHtml

-- it's HTML. not too much involved.
indexView showData = html . renderHtml $ layout "tracker" $ do
  div ! class_ "container-fluid" $
    div ! id "app" $ mempty
  injectScript $ printf "showData = JSON.parse(%s);" showData
  script ! src "index.js" $ mempty

And that's about it, really, for my Haskell code.

Elm

It's a pretty normal Elm application now that you don't have a lot of flexibility in 0.17. I do take the show updates and shove them in ports though, as the normal HTTP solutions provided in other user libs don't have enough for what I need.

main : Program { showData : List Show }
main =
  programWithFlags
    { init = \x -> ( { initModel | showData = x.showData }, Cmd.none )
    , view = view
    , update = update
    , subscriptions = subscriptions
    }

port showDataUpdates : (Show -> msg) -> Sub msg

subscriptions : Model -> Sub Msg
subscriptions _ =
  Sub.batch
    [ downs handleKeyDown
    , ups handleKeyUp
    , showDataUpdates ShowDataUpdate
    ]

port updateRequests : { target : String, name : String } -> Cmd msg

updateShow : String -> Model -> Cmd msg
updateShow target { showData, cursor } =
  let name =
    case showData !! cursor of
      Just x -> x.name
      Nothing -> ""
  in
    updateRequests
      { target = target
      , name = name
      }

incrementShow = updateShow "increment"
decrementShow = updateShow "decrement"

And so for the JS code that uses this:

var Elm = require('./Main.elm');

var app = Elm.Main.embed(document.getElementById('app'), {
  showData: window.showData
});

app.ports.updateRequests.subscribe(function (x) {
  console.log(x)
  const xhr = new XMLHttpRequest();
  xhr.open('POST', x.target, true);
  xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
  xhr.send('name=' + x.name);
  xhr.onload = function (e) {
    app.ports.showDataUpdates.send({
      name: x.name,
      count: e.target.response
    });
  };
});

And that's it for my front-end code.

Deployment on Heroku

There's a few things we need to deploy to Heroku.

Environment

I went through and made a Heroku app and used the Heroku Redis add-on. I then linked my Github project, set the buildpack to https://github.com/mfine/heroku-buildpack-stack, and set my environment variables needed for this project (REDIS_HOST, etc).

Code

You'll have to make a Procfile in your repo, so I did so with web: .local/bin/tracker-hs. Note that you'll have to match your executable target's name in your cabal file and make sure the path to the binary is correct. Otherwise, you can start digging with heroku run bash.

After all this, you should be able to deploy a branch and have it all work. Note that for my front-end, I'm choosing to build the front-end and push a prepared-dist branch, since I'm too lazy to do that correctly (you might use Travis to do that kind of thing, maybe).

Links