Tabula Rasa
A minimalistic real-worldish blog engine written entirely in F#. Specifically made as a learning resource when building apps with the SAFE stack. This application features many concerns of large apps such as:
- Using third-party react libraries via interop
- Deep nested views
- Deep nested routing
- Message interception as means for component communication
- Logging
- Database access
- User security: authentication and authorization
- Type-safe RPC communication
- Realtime type-safe messaging via web sockets
Screen recordings
The server uses the following tech
- Suave as a lightweight web server
- LiteDB as a lightweight embedded database through LiteDB.FSharp
- Serilog for logging through Suave.SerilogExtension
- Jose for generating secure JSON web tokens
- Fable.Remoting for type-safe communication
- Elmish.Bridge for real-time type-safe messaging in an elmish model
- Expecto for testing
The client uses the following tech
- Elmish for building the client architecture with react
- Bootstrap for styling
- Elmish.Toastr for toasts/notifications
- Elmish.SweetAlert for simple and sweet elmish dialogs prompts
- Fable.Remoting for type-safe communication
- Elmish.Bridge for real-time type-safe messaging in an elmish model
- Third-party javascript libraries
- react-select for adding tags to posts
- react-event-timeline for a timeline view of the blog posts
- react-marked-markdown for rendering markdown
- react-responsive for making the app responsive
Communication Protocol
To understand how the application works and what it does, you simply take a look the protocol between the client and server:
type IBlogApi = {
getBlogInfo : unit -> Async<Result<BlogInfo, string>>
login : LoginInfo -> Async<LoginResult>
getPosts : unit -> Async<list<BlogPostItem>>
getPostBySlug : string -> Async<Option<BlogPostItem>>
getDrafts : AuthToken -> SecureResponse<list<BlogPostItem>>
publishNewPost : SecureRequest<NewBlogPostReq> -> SecureResponse<AddPostResult>
savePostAsDraft : SecureRequest<NewBlogPostReq> -> SecureResponse<AddPostResult>
deleteDraftById : SecureRequest<int> -> SecureResponse<DeleteDraftResult>
publishDraft : SecureRequest<int> -> SecureResponse<PublishDraftResult>
deletePublishedArticleById : SecureRequest<int> -> SecureResponse<DeletePostResult>
turnArticleToDraft: SecureRequest<int> -> SecureResponse<MakeDraftResult>
getPostById : SecureRequest<int> -> SecureResponse<Option<BlogPostItem>>
savePostChanges : SecureRequest<BlogPostItem> -> SecureResponse<Result<bool, string>>
updateBlogInfo : SecureRequest<BlogInfo> -> SecureResponse<Result<SuccessMsg, ErrorMsg>>
togglePostFeatured : SecureRequest<int> -> SecureResponse<Result<string, string>>
updatePassword : SecureRequest<UpdatePasswordInfo> -> SecureResponse<Result<string, string>>
}
Thanks to Fable.Remoting, this application does not need to handle data serialization/deserialization and routing between client and server, it is all done for us which means that the code is 99% domain models and domain logic.
You will often see calls made to server from the client like these:
| ToggleFeatured postId ->
let nextState = { state with IsTogglingFeatured = Some postId }
let request = { Token = authToken; Body = postId }
let toggleFeatureCmd =
Cmd.fromAsync {
Value = Server.api.togglePostFeatured request
Error = fun ex -> ToggleFeaturedFinished (Error "Network error while toggling post featured")
Success = function
| Error authError -> ToggleFeaturedFinished (Error "User was unauthorized")
| Ok toggleResult -> ToggleFeaturedFinished toggleResult
}
nextState, toggleFeatureCmd
Client Application Layout
The client application layout is how the components are structured in the project. The components are written in a consistent pattern that is reflected by the file system as follows:
ParentComponent
|
| - Types.fs
| - State.fs
| - View.fs
| - ChildComponent
|
| - Types.fs
| - State.fs
| - View.fs
Where the client is a tree of UI components:
App
|
| - About
| - Posts
|
| - SinglePost
| - AllPosts
|
| - Admin
|
| - Login
| - Backoffice
|
| - PublishedArticles
| - Drafts
| - Settings
| - NewArticle
| - EditArticle
Component Types
Every component comes with a Types.fs
file that contains mostly three things
State
data model that the component keeps track ofMsg
type that represents the events that can occurPages
represents the current page and sub pages that a component can have
The State
keeps track of the CurrentPage
but it will never update it by hand: the CurrentPage
is only updated in response to url changes and these changes will dispatch a message to change the value of the CurrentPage
along with dispatching other messages related to loading the data for the component
Important Concepts: Data Locality and Message Interception
Following these principles to help us write components in isolation:
- Child components don't know anything about their parents
- Child components don't know anything about their siblings
- Parent components manage child state and communication between children
The best example of these concepts is the interaction between the following components:
Admin
|
---------------
| |
Backoffice Login
Message Interception by example
Definition: Message interception is having control over how messages flow in your application, allowing for communication between components that don't know each other even exist.
Login
doesn't know anything going on in the application as a whole, it just has a form for the user to input his credentials and try to login to the server to obtain an authorization token. When the token is obtained, a LoginSuccess token
message is dispatched. However, this very message is intercepted by Admin
(the parent of Login
), updating the state of Admin
:
// Admin/State.fs
let update msg (state: State) =
match msg with
| LoginMsg loginMsg ->
match loginMsg with
// intercept the LoginSuccess message dispatched by the child component
| Login.Types.Msg.LoginSuccess token ->
let nextState =
{ state with Login = state.Login
SecurityToken = Some token }
nextState, Urls.navigate [ Urls.admin ]
// propagate other messages to child component
| _ ->
let nextLoginState, nextLoginCmd = Admin.Login.State.update loginMsg state.Login
let nextAdminState = { state with Login = nextLoginState }
nextAdminState, Cmd.map LoginMsg nextLoginCmd
After updating the state of Admin
to include the security token obtained from Login
, the application navigates to the admin pages using Urls.navigate [ Urls.admin ]
. Now the navigation will succeed, because navigating to the admin is allowed only if the admin has a security token defined:
// App/State.fs -> inside handleUpdatedUrl
| Admin.Types.Page.Backoffice backofficePage ->
match state.Admin.SecurityToken with
| None ->
// navigating to one of the admins backoffice pages
// without a security token? then you need to login first
Cmd.batch [ Urls.navigate [ Urls.login ]
showInfo "You must be logged in first" ]
| Some userSecurityToken ->
// then user is already logged in
// for each specific page, dispatch the appropriate message
// for initial loading of that data of that page
match backofficePage with
| Admin.Backoffice.Types.Page.Drafts ->
Admin.Backoffice.Drafts.Types.LoadDrafts
|> Admin.Backoffice.Types.Msg.DraftsMsg
|> Admin.Types.Msg.BackofficeMsg
|> AdminMsg
|> Cmd.ofMsg
| Admin.Backoffice.Types.Page.PublishedPosts ->
Admin.Backoffice.PublishedPosts.Types.LoadPublishedPosts
|> Admin.Backoffice.Types.Msg.PublishedPostsMsg
|> Admin.Types.Msg.BackofficeMsg
|> AdminMsg
|> Cmd.ofMsg
| Admin.Backoffice.Types.Page.Settings ->
Admin.Backoffice.Settings.Types.Msg.LoadBlogInfo
|> Admin.Backoffice.Types.Msg.SettingsMsg
|> Admin.Types.Msg.BackofficeMsg
|> AdminMsg
|> Cmd.ofMsg
| Admin.Backoffice.Types.Page.EditArticle postId ->
Admin.Backoffice.EditArticle.Types.Msg.LoadArticleToEdit postId
|> Admin.Backoffice.Types.Msg.EditArticleMsg
|> Admin.Types.Msg.BackofficeMsg
|> AdminMsg
|> Cmd.ofMsg
| otherPage ->
Cmd.none
Another concrete example in this application: when you update the settings, the root component intercepts the "Changed settings" message and reloads it's blog information with the new settings accordingly
Data Locality by example
Definition: Data Locality is having control over the data that is available to certain components, without access to global state.
Fact: Components of Backoffice
need to make secure requests, hence they need a security token available whenever a request is to be made.
Requirement: Once the user is inside a component of Backoffice
, there will always be a SecurityToken
available to that component. This is because I don't want to check whether there is a security token or not everytime I want to make a web request, because if there isn't one, there is an internal inconsistency: the user shouldn't have been able to reach the Backoffice
component in the first place.
Problem: The security token is only acquired after the user logs in from Login
, but before that there isn't a security token, hence the type of the token will be SecurityToken: string option
but we don't want an optional token, we want an actual token once we are logged in.
Solution: Login
and components of Backoffice
cannot be siblings, Login
is happy with the security token being optional, while Backoffice
insists on having a token at any given time. So we introduce a parent: Admin
that handles the optionalness of the security token! The Admin
will disallow the user from reaching Backoffice
if there isn't a security token, and if there is one, it will be propagated to the backoffice:
// Admin/State.fs -> update
| BackofficeMsg msg ->
match msg with
| Backoffice.Types.Msg.Logout ->
// intercept logout message of the backoffice child
let nextState, _ = init()
nextState, Urls.navigate [ Urls.posts ]
| _ ->
match state.SecurityToken with
| Some token ->
let prevBackofficeState = state.Backoffice
let nextBackofficeState, nextBackofficeCmd =
// pass security token down to backoffice
Backoffice.State.update token msg prevBackofficeState
let nextAdminState = { state with Backoffice = nextBackofficeState }
nextAdminState, Cmd.map BackofficeMsg nextBackofficeCmd
| None ->
state, Cmd.none
Unit-testable at the composition root level:
The composition root is where the application functionality gets all the dependencies it needs to run to application like the database and a logger. In this application, the composition root is where we construct an implementation for the IBlogApi
protocol:
let liftAsync x = async { return x }
/// Composition root of the application
let createBlogApi (logger: ILogger) (database: LiteDatabase) : IBlogApi =
// create initial admin guest admin if one does not exists
Admin.writeAdminIfDoesNotExists database Admin.guestAdmin
let getBlogInfo() = async { return Admin.blogInfo database }
let getPosts() = async { return BlogPosts.getPublishedArticles database }
let blogApi : IBlogApi = {
getBlogInfo = getBlogInfo
getPosts = getPosts
login = Admin.login logger database >> liftAsync
publishNewPost = BlogPosts.publishNewPost logger database
getPostBySlug = BlogPosts.getPostBySlug database >> liftAsync
savePostAsDraft = BlogPosts.saveAsDraft logger database
getDrafts = BlogPosts.getAllDrafts database
deleteDraftById = BlogPosts.deleteDraft logger database
publishDraft = BlogPosts.publishDraft database
deletePublishedArticleById = BlogPosts.deletePublishedArticle database
turnArticleToDraft = BlogPosts.turnArticleToDraft database
getPostById = BlogPosts.getPostById database
savePostChanges = BlogPosts.savePostChanges database
updateBlogInfo = Admin.updateBlogInfo database
togglePostFeatured = BlogPosts.togglePostFeatured database
updatePassword = Admin.updatePassword logger database
}
blogApi
Because LiteDB already includes an in-memory database and Serilog provides a simple no-op logger, you can write unit tests right off the bat at the application level:
// creates a disposable in memory database
let useDatabase (f: LiteDatabase -> unit) =
let mapper = FSharpBsonMapper()
use memoryStream = new MemoryStream()
use db = new LiteDatabase(memoryStream, mapper)
f db
testCase "Login with default credentials works" <| fun _ ->
useDatabase <| fun db ->
let logger = Serilog.Log.Logger
let testBlogApi = WebApp.createBlogApi logger db
let loginInfo = { Username = "guest"; Password = "guest" }
let result = Async.RunSynchronously (testBlogApi.login loginInfo)
match result with
| LoginResult.Success token -> pass()
| _ -> fail()
Of course you can also test the individual functions seperately because every function is also unit testable as long as you provide a database instance and a logger.
Responsive using different UI's
As opposed to using CSS to show or hide elements based on screen size, I used react-responsive to make a completely different app for small-sized screens, implemented as
let app blogInfo state dispatch =
div
[ ]
[ mediaQuery
[ MinWidth 601 ]
[ desktopApp blogInfo state dispatch ]
mediaQuery
[ MaxWidth 600 ]
[ mobileApp blogInfo state dispatch ] ]
Security with JWT
User authentication and authorization happen though secure requests, these requests include the JSON web token to authorize the user. The user acquires these JWT's when logging in and everything is stateless. An example of a secure request with it's handler on the server:
// Client
| ToggleFeatured postId ->
let nextState = { state with IsTogglingFeatured = Some postId }
let request = { Token = authToken; Body = postId }
let toggleFeatureCmd =
Cmd.fromAsync {
Value = Server.api.togglePostFeatured request
Error = fun ex -> ToggleFeaturedFinished (Error "Network error while toggling post featured")
Success = function
| Error authError -> ToggleFeaturedFinished (Error "User was unauthorized")
| Ok toggleResult -> ToggleFeaturedFinished toggleResult
}
nextState, toggleFeatureCmd
And it is handled like this on the server:
// Server
let togglePostFeatured (db: LiteDatabase) =
Security.authorizeAdmin <| fun postId admin ->
let posts = db.GetCollection<BlogPost> "posts"
match posts.tryFindOne <@ fun post -> post.Id = postId @> with
| None -> Error "Blog post could not be found"
| Some post ->
let modifiedPost = { post with IsFeatured = not post.IsFeatured }
if posts.Update modifiedPost
then Ok "Post was successfully updated"
else Error "Error occured while updating the blog post"
See Modeling Authentication and Authorization in Fable.Remoting to learn more
Try out on your machine
Requirements:
Start watch build on windows:
git clone https://github.com/Zaid-Ajaj/tabula-rasa.git
cd tabula-rasa
build.cmd Watch
On linux/mac you can use bash
git clone https://github.com/Zaid-Ajaj/tabula-rasa.git
cd tabula-rasa
./build.sh Watch
This will start the build and create the LiteDb
(single file) database for the first time if it does not already exist. The database will be in the
application data directory of your OS under the tabula-rasa
directory with name TabulaRasa.db
along with the newly generated secret key used for generating secure Json web tokens. The
"application data directory" on most linux systems will be ~/.config/
, resulting in a directory
~/.config/tabula-rasa/
.
When the build finishes, you can navigate to http://localhost:8090
to start using the application. Once you make changes to either server or client, it will automatically re-compile the app.
Once the application starts, the home page will tell you "There aren't any stories published yet" because the database is still empty. You can then navigate to http://localhost:8090/#login
to login in as an admin who can write stories. The default credentials are Username = guest
and Password = guest
.
More
There is a lot to talk about with this application, but the best way to learn from it is by actually trying it out and going through the code yourself. If you need clarification or explanation on why a code snippet is written the way it is, just open an issue with your question :)