Yesod, Angular, Bower, and NodeJs Tips
By John Lenz. February 4, 2015.
A while back I posted about Yesod and Angular and testing, but those articles were written at the start of my main project with Yesod and Angular. For the most part what I described in those earlier posts works well, but here is a list of organizational tips after my experience writing a large Yesod and Angular project.
File Layout / Scaffold
I started with the yesod scaffold, but made several changes and additions. Here is the list of all the files and directories I am currently using.
First, the files from the yesod scaffold. The main change was move the code into the lib
subdirectory, remove the static folder, and switch the static subsite to the embedded static subsite (see more details below).
- app/main.hs - same as scaffold, the main program for production
- devel.hs - same as scaffold, for yesod devel
- config/* - same as scaffold
- messages/* - same as scaffold, although I switched to a directory named
en
containing several message files. - templates/* - same as scaffold, except this is just hamlet and cassius
- lib/[Foundation.hs, Settings.hs, Handler/Home.hs, ...] - I moved all the Haskell files from the scaffold into the
lib
subdirectory, since the top-level directory is quite crowded. I then updated the cabal file to usehs-source-dirs: lib
- tests/unit-haskell/[spec.hs, HomeSpec.hs, ...] - the test suite from the scaffold, moved into the
unit-haskell
subdirectory.
These tests use yesod-test to unit test various code in the lib directory. .cabal
To replace the static subdirectory, I use bower.
- bower.json - this file lists just the
name
(since it is required) and a list ofdependencies
with exact versions for the javascript libraries I am using. - bower_components/* - not in revision control, this is created by
bower update
and contains the javascript files for the
dependencies. Files from here are included in the app via the embedded static subsite (see below).
Angular unit testing is done through karma, and to install karma I use the following:
- package.json - contains just a name and a list of dependencies. The dependencies are
karma
,karma-jasmine
,karma-firefox-launcher
,karma-phantomjs-launcher
,karma-ng-hamlet2js-preprocessor
. - node_modules/ - not in revision control, this is created by
npm update
and contains karma and its dependencies.
Now the angular code
- angular/
/ .js - one directory for each angular module, and the javascript files within the directory are formatted according to the Yesod.EmbeddedStatic.Angular module. - tests/unit-js/
/ .js - one directory for each angular module, and the javascript files within the directory are jasmine tests written using angular-mock.js. See the example app for some example test code. - karma.conf.js - config file for karma, loading angular and angular mock from
bower_components
, my angular code fromangular
, and the test code fromtests/unit-js
Finally the End 2 End tests written in Haskell using the webdriver, hspec-webdriver, and webdriver-angular packages.
- tests/end2end/[spec.hs, HomeSpec.hs, ...] - webdriver test code. This directory is compiled into a second test suite inside the cabal file
Bower and Yesod
Bower and the embedded static subsite work very well together. By listing in bower.json
the exact dependencies, it means that whatever computer I develop on will have the exact versions of angular, moment.js, and so on. The embedded static subsite then allows picking out various files and directories and embedding them into the application. For example, a snippet of lib/StaticFiles.hs
is
#ifdef DEVELOPMENT
#define DEV_BOOL True
#else
#define DEV_BOOL False
#endif
mkEmbeddedStatic DEV_BOOL "staticSubsite" [
-- local angular modules
embedNgModules "ng" "angular" uglifyJs
-- bootstrap
, embedFileAt "css/bootstrap.min.css" "bower_components/bootstrap/dist/css/bootstrap.min.css"
, embedDirAt "fonts" "bower_components/bootstrap/dist/fonts"
-- angular
, concatFiles "js/angular.js"
#if DEVELOPMENT
[ "bower_components/angular/angular.js"
, "bower_components/angular-bootstrap/ui-bootstrap-tpls.js"
]
#else
[ "bower_components/angular/angular.min.js"
, "bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js"
]
#endif
...
For other libraries like spin.js which do not come pre-compressed, the concatFilesWith
function with a compressor like uglifyJs
can minify the library at compile time.
Development Practice
A big part of Angular is testing and my plan which I described in this post worked exceptionally well for a test-driven development process. I tend to focus only on one of three tasks at a time:
Writing the backend routes (not templates). For example, the REST routes talking to the database, user accounts, etc. When developing this code, I am constantly writing tests in the
tests/unit-haskell
directory. I will runcabal repl unit
and be constantly writing and running the tests for the routes as I develop the routes. Note that I am not testing or developing any view/templates here so no need to be runningyesod devel
, justcabal repl unit
is enough.Writing the angular code. The main focus of angular is providing a DSL via directives for the eventual view, so while developing this code I am not running any Haskell code at all. Instead, I run
./node_modules/karma/bin/karma start
, develop the various angular services, filters, and directives, and write jasmine specs using the angular mocks in thetests/unit-js
directory.Writing the view, i.e. the hamlet in the
templates/
directory. While writing these templates, I write/run the end2end test suite written using webdriver. I haven't found a good way to run bothyesod devel
andcabal repl end2end
at the same time. One option is to give the end2end test suite its own cabal file, but instead what I found is that I actually don't need to be recompiling the view/templates that much since both the routes and the angular directives have already been tested. So instead what I do is test with the actual production build running with the Staging config. So I work on the end2end tests usingcabal repl end2end
and accumulate a bunch of view fixes/changes that need to be made. I then make those changes, rebuild the production Haskell code, restart the server, and continue developing the end2end tests and the view.As you can see above, I rarely use
yesod devel
. About the only time I use it is when I am styling the app, writing the cassius files for the view.
Hamlet variable interpolation
While rare, at the beginning I used a few variable interpolations while writing the view in the templates/*.hamlet
files, mostly for information about the currently logged in user and what permissions/resources/pages they could access. But after a while I found this suboptimal. These values angular did not know about, couldn't be fed through filters, and especially felt weird since formatting for display was taking place in two places. So instead I removed all variable interpolation from these templates. Instead, I interpolate the values into an angular module and then access them via dependency injection. To do so, inside defaultLayout
(actually navLayout
, see below), I have code
toWidget [julius|angular.module("myapp:config", []);|]
This creates a new angular module (because of the second parameter) which can be relied upon to always exist (since it is in defaultLayout), so that later in my actual handlers, I can write code like
muser <- liftHandlerT maybeAuthId
toWidget [julius|angular.module("myapp:config").constant("user", #{maybe Null toJSON muser});|]
and similar interpolations for other values. Then my actual angular modules can depend on "myapp:config" and access the values via dependency injection. This way I can export the raw values from the server and keep all the formatting inside one place, in the angular filters.
Since this javascript is constantly changing, it makes no sense for it to be split into a separate file and should instead be embedded directly into the HTML file. Therefore, I have no implementation of addStaticContent
in my Yesod instance, which causes Yesod to stick the javascript inline at the bottom of the body. This is actually perfect, since the above config code is essentially all the javascript code on each page. (The majority of the javascript is the angular modules, served by the embedded static subsite via the angular generator).
Angular ng-app
The preferred method of bootstrapping angular is via the ng-app
directive. This was a slight problem, since the defaultLayout
includes a responsive navigation bar. To collapse the navigation bar via angular-bootstrap (I use that instead of jquery and the bootstrap javascript), the navigation bar needs to be inside the ng-app
. Also, some pages don't use angular (besides the bootstrap collapse code). Minimized and compressed, angular plus angular-bootstrap is only slightly larger than jquery and bootstrap.js, plus the angular code will be needed if the user navigates to a page which uses it, so I decided to just use angular even for the pages that don't require it. To do so, in lib/Foundation.hs
I have a function
navLayout :: Text -- ^ angular module name
-> Widget
-> Handler Html
navLayout angularModule widget = do
-- code similar to the scaffold defaultLayout function
-- using $(widgetFile "default-layout") and default-layout-wrapper.hamlet
-- where default-layout.hamlet has <div ng-app=#{angularModule}>
Then in the Yesod instance I have
instance Yesod App where
....
defaultLayout widget =
navLayout "default-mod" $ do
toWidget [julius|angular.module('default-mod', ['ui.bootstrap']);|]
widget
Since there is no addStaticContent
(see the previous section), this little snippet of javascript is embedded at the bottom of the page.
Angular $http and XSRF
The angular $http service has a few security protections. The first is XSRF protection, which I take advantage of via the following two functions which live in lib/Foundation.hs
-- | XSRF protection to match angular's $http service.
--
-- Sets the XSRF-TOKEN cookie which will cause angular's $http service to include a header
-- X-XSRF-TOKEN. Warning: the XSRF-TOKEN cookie itself cannot be relied upon since it will be sent
-- in any request so the attacker does not need to forge it. We must check only the header, since
-- only javascript running on our domain will be able to read the cookie and so generate the header.
setXsrfCookie :: MonadHandler m => m ()
setXsrfCookie = do
req <- getRequest
setCookie $ def { setCookieName = "XSRF-TOKEN"
, setCookieValue = encodeUtf8 $ fromMaybe (error "No Token!") $ reqToken req
, setCookiePath = Just "/"
, setCookieSecure = production
}
-- | Use in every REST route to protect from XSRF attacks.
checkXsrfHeader :: MonadHandler m => m ()
checkXsrfHeader = do
req <- getRequest
case lookup "X-XSRF-TOKEN" $ WAI.requestHeaders $ reqWaiRequest req of
Just token | Just (decodeUtf8 token) == reqToken req -> return ()
Nothing -> invalidArgs ["XSRF token is missing"]
_ -> permissionDenied "XSRF token is incorrect"
Secondly, to protect against attacks where the attacker redefines the array constructor and loads the JSON as a script, angular will strip )]}'\n
from the start of any JSON response. We can take advantage of the TypedContent
typeclass to do this automatically for us, via the following code in lib/Foundation.hs
.
-- | A JSON value that will be protected against attackers attempting to treat the JSON response as
-- javascript code, by prepending )]}',\n. Currently this is only known to be possible when the JSON
-- is an array, but there is little harm in being careful. Angular's $http service will automatically
-- strip this prefix. Use as follows
--
-- >getSomeRouteR :: Handler SafeValue
-- >getSomeRouteR = do
-- > ....
-- > returnSafeJson x
newtype SafeValue = SafeValue Value
deriving (Eq, Show, FromJSON, ToJSON)
returnSafeJson :: (Monad m, ToJSON a) => a -> m SafeValue
returnSafeJson = return . SafeValue . toJSON
instance ToContent SafeValue where
toContent (SafeValue v) =
case toContent v of
ContentBuilder b Nothing -> ContentBuilder (toBuilder (")]}',\n" :: ByteString) ++ b) Nothing
_ -> error "Value instance produced a different content"
instance HasContentType SafeValue where
getContentType _ = typeJson
instance ToTypedContent SafeValue where
toTypedContent v = TypedContent typeJson (toContent v)
ui-router, URLs, and html5Mode
Using the html5Mode
of the $location
service and ui-router requires the server side to ignore the route component of the URL. This can be easily done by adding something like
/somepath/*[Text] SomePathR GET
to the routes and then in the handler for SomePathR
just ignore the [Text]
parameter and serve the same HTML. Next, we need to set <base href="/somepath/">
inside the HTML so that angular knows which part of the URL is the routes. While this could be hard coded, using hamlet url interpolation is better. The main issue is that the default rendering of SomePathR []
will not have a trailing slash but angular requires a trailing slash in the base
tag. While you could do something like <base href="@{SomePathR []}/">
, I instead customized urlRenderOverride
to add a trailing slash, but only for the SomePathR []
route, other routes (including routes where the list is non-empty, should be left for the default rendering.
This then leads to an annoyance because due to the default cleanPath
, a get to /somepath/
will redirect to /somepath
. This actually works because the route SomePathR
will match /somepath
(the trailing slash rules of yesod are pretty confusing) so the handler will be run. But then immedietly angular will change the url using $location
to include the trailing slash plus the url of the state. So we have a useless redirect that just flashes briefly without a trailing slash and then it comes back. Therefore, I changed cleanPath
to
cleanPath _ pieces =
case pieces of
["somepath", ""] -> Right pieces
_ | pieces == corrected -> Right pieces
_ -> Left corrected
where
corrected = filter (not . null) pieces
Note here we only allow the trailing slash exactly for the URL /somepath/
and all others strip the trailing (and other empty paths). Note that the default cleanPath
also replaces -
but since I wasn't using that I didn't add it.
The result is that from hamlet templates we can link to SomePathR []
and it will render as /somepath/
, or even link directly to some route state via SomePathR ["some", "state"]
and it will render as /somepath/some/state
. GETs to both of these URLs return the HTML directly without redirects and angular then updates to the correct state. While a GET to /somepath
also works and we could consider redirecting /somepath
to /somepath/
inside cleanPath
, nothing links to /somepath
so I just left it.
Places for improvements
An incomplete list of places where an alternative design than what I picked would be nice.
Type safer routes: the REST routes are accessed from angular javascript code and currently I hard-code the route strings. Ideally, the routes should be exported as values on the
myapp:config
module (see above), ideally insidedefaultLayout
andnavLayout
. Perhaps if the route type generated by the yesod TH implemented the Generic class, we could automatically create config entries for each route. Alternatively some TH code can be written.i18n: Currently I use angular-translate for the angular messages and the normal yesod messages for the haskell messages, and while I tried to keep the duplication to a minimum, some messages are duplicated. Some way of combining these messages is needed. Ideally, there would be one set of message files and both angular-translate and yesod would both work from the same set of messages. The only hard part is messages with parameters: we would need a file format which allowed both haskell and javascript fragments, and then write some template haskell to replace the yesod
mkMessage
function and some javascript code to parse these files. The javascript side would be relatively easy since we could just manipulate the values before passing to angular-translate, but not sure about the TH code.Fay, Elm, Purescript, GhcJS, etc. At the moment, a full-blown binding isn't needed. Essentially I would like to write angular services in a decent language. This would require no angular specific bindings so is doable. Angular services are where the bulk of the data manipulation and logic in an angular app should live and is the case for me. Angular controllers and directives I have no problem leaving in javascript, since they are at least in my app a very small amount of code and with karma can be tested easily. I am leaning towards PureScript at the moment to write the angular services. I have written a couple largish services in pure javascript, which wasn't so bad due to how awesome karma is. But this is definitely something I am looking at.