I recently open sourced my first large PureScript project. It’s a slack bot that allows searching for cards from the Epic Card Game. In this post, I’ll discuss the process of writing the application, what went well and what went poorly.
An Overview of the Slack Bot
I was excited about building this project in PureScript because it was complicated enough to be interesting but not so complicated as to be overwhelming. The key requirements for the bot were:
- Handle incoming HTTP requests and validate a signature
- Scrape the Epic Card Game site for card names and images (no API exists)
- Index the card names for in-memory full-text search
- Parse JSON requests and generate JSON responses
Notably, one feature that was not required was a database.
Web Framework
I chose HTTPure for my web framework. The library has a simple design, good general documentation and good examples of using middleware.
I used middleware for two primary reasons: validating slack signatures and running my application monad. The middleware design worked nicely for both of these use cases.
Routing
was very straightforward with only two paths. The root path handles all commands
and the /interactive
path handles interactive input from the user.
Here’s a look at the router:
module Epicbot.Web.Router
( new
) where
import Epicbot.App (ResponseM)
import Epicbot.Web.Service.Command as CommandService
import Epicbot.Web.Service.Interactive as InteractiveService
import HTTPure as HTTPure
new :: HTTPure.Request -> ResponseM
new req = case req of
{ path: [] } ->
CommandService.handle req
{ path: ["interactive"] } ->
InteractiveService.handle req
_ ->
HTTPure.notFound
Web Scraping
To retrieve details about Epic Card Game’s cards, I used a combination of the PureScript Milkis library to make HTTP requests and JavaScript’s cheerio to extract data from the HTML responses.
Making HTTP requests with Milkis was a simple one-liner. When the application is in offline mode (for testing) I simply read a fixture from my filesystem rather than making an HTTP request.
module Epicbot.Scraper
( scrape
) where
import Prelude
import Effect.Aff (Aff)
import Epicbot.Card (Card)
import Epicbot.Http as Http
import Epicbot.Html.Parser as Parser
import Epicbot.OnlineStatus (OnlineStatus(..))
import Milkis as Milkis
import Node.Encoding (Encoding(UTF8))
import Node.FS.Aff as FS
testDocPath :: String
testDocPath = "./data/card-gallery.html"
prodUrl :: Milkis.URL
prodUrl = Milkis.URL "http://www.epiccardgame.com/card-gallery/"
getPage :: OnlineStatus -> Aff String
getPage Offline = FS.readTextFile UTF8 testDocPath
getPage Online = Milkis.text =<< Http.get prodUrl
scrape :: OnlineStatus -> Aff (Array Card)
scrape onlineStatus = Parser.parseCards <$> getPage onlineStatus
My HTML parsing code was only 30 or so lines of JavaScript and some FFI in PureScript.
Full-Text Search
I decided to use JavaScript’s elasticlunr for full-text search. This is the totality of the JavaScript code for building the index, adding documents and searching:
const elasticlunr = require("elasticlunr");
exports._addDoc = function (doc, index) {
index.addDoc(doc);
return index;
};
exports._newDocIndex = elasticlunr(function () {
this.addField("name");
this.setRef("id");
});
exports._searchDoc = function (term, index) {
return index.search(term, {});
};
On the PureScript side, I’m mostly using FFI and wrapping the JavaScript in a more idiomatic interface.
JSON Handling
I used Argonaut to handle JSON parsing and generation. Here’s an example of some custom JSON parsing and generation I’m doing to interact with the Slack API:
newtype Action = Action
{ name :: Maybe String
, text :: Maybe String
, type :: Maybe String
, value :: Maybe String
}
derive newtype instance eqAction :: Eq Action
derive newtype instance ordAction :: Ord Action
derive newtype instance showAction :: Show Action
instance encodeJsonAction :: EncodeJson Action where
encodeJson :: Action -> Json
encodeJson (Action obj) = do
"value" :=? obj.value
~>? "type" :=? obj.type
~>? "text" :=? obj.name
~>? "name" :=? obj.name
~>? jsonEmptyObject
instance decodeJsonAction :: DecodeJson Action where
decodeJson :: Json -> Either String Action
decodeJson json = do
obj <- decodeJson json
name <- obj .:? "name"
text <- obj .:? "text"
t <- obj .:? "type"
value <- obj .:? "value"
pure $ Action { name, text, type: t, value }
Yes, it’s probably wrong to have a bunch of Maybe String
s in a record. I’m
still learning.
Application Monad
I decided to follow the ReaderT design
pattern when
architecting my application. Here’s the definition of my App
type:
newtype App a = App (ReaderT RequestEnv Aff a)
It’s a simple newtype
over a ReaderT
. The RequestEnv
type represents the
application configuration (e.g. the full-text search index) as well as
request-specific configuration (e.g. the unique request id). The base monad is
Aff, PureScript’s
asynchronous effect monad.
The Good Stuff
Other than the fact that I was actively learning the PureScript language while building the bot, most things went remarkably well. I’m very happy with the final application, though I might build some parts differently were I starting today.
HTTPure is an excellent web framework. It’s both simple and powerful and its middleware implementation seems unrivaled in the PureScript ecosystem.
The ability to FFI into the JavaScript ecosystem is also a huge boon. Both
elasticlunr
and cheerio
made quick work of what could have been very
challenging problems. Even though I was relying on “unsafe” JS code for these
portions of the application, once the FFI was in place and the JS code was
written, I’ve never had a runtime issue with these seams. In a more robust
production system, I may choose to use Foreign
at my FFI boundaries, but
avoiding that here worked fine in practice.
Spago, PureScript’s package manager and build tool, is an absolutely delight to use. It has spoiled me for other ecosystem’s package managers.
The editor tooling in PureScript is also fantastic thanks to the PureScript Language Server. Again, this makes it hard for me to go back to other languages.
Last, but certainly not least, the PureScript language itself is a pleasure to work with. When I began learning it, I had no experience with Haskell nor any other pure functional language. I’ve felt incredibly productive in PureScript once over the initial learning curve. It feels both light-weight and powerful. While the community is small, the people are fantastic and the available libraries are top notch. Much to my surprise, it even has excellent documentation.
The Less Good Stuff
Because I’m still new to pure functional languages, there are places where I can acutely feel the boilerplate. Here’s the minimum amount of code required to build a custom application monad:
newtype App a = App (ReaderT RequestEnv Aff a)
derive newtype instance functorApp :: Functor App
derive newtype instance applyApp :: Apply App
derive newtype instance applicativeApp :: Applicative App
derive newtype instance bindApp :: Bind App
derive newtype instance monadApp :: Monad App
derive newtype instance monadEffectApp :: MonadEffect App
derive newtype instance monadAffApp :: MonadAff App
instance monadAskApp :: TypeEquals e RequestEnv => MonadAsk e App where
ask = App $ asks from
The Slack API itself is quite finicky, often times having different behavior if
a JSON value is included with a null
value or excluded completely. This led to
lots of
verbose
JSON generation. This is more of a comment on the Slack API than any particular
PureScript feature. It’s also entirely possible that I could structure this code
differently to make it less verbose.
PureScript is a small language and ecosystem and this led to some DIY that I
wasn’t expecting. For example, I couldn’t find an existing library for parsing
an application/x-www-form-urlencoded
body. I ended up writing one
myself
and learning parser combinators in the process, which was great, but this was a
piece of work I wasn’t expecting. I also had to write my own implementation for
shuffling an
Array
(note to reader: my implementation is horrible).
Overall Impression
This project has only increased my enthusiasm around PureScript. It was a joy to use and I’m happy with the resulting application. The language feels flexible, light-weight and very well designed.
I’m also watching the PureScript Native project excitedly. The ability to target the go ecosystem as an alternative backend would be fantastic.