DEV Community

Alexey Tukalo
Alexey Tukalo

Posted on • Originally published at Medium

An experimental typesafe back-end micro-framework for Elm

A bit more than a year ago, I started an exciting open-source project. The goal was to improve my understanding of backend development by the creation of a toy back-end micro-framework. The article is dedicated to the results of my work.

I selected Elm as a platform for my project due to a lack of existing back-end infrastructure and dynamic community, which I expected to attract by my creation. Unfortunately, I was not aware of the underlying change in internal policies, which led to a complete ban on any native code on third-party Elm packages. The fact caused a misunderstanding, which didn’t let me get any collaboration from a core Elm development team. So, I decided to finish it anyway and let’s take a look at the outcome.

router: Request String -> Mode String (Answer String State String)
router =
    -- Default router prints requests with specified prefix as default actions
    logger "Request"
        -- Synchronously handle GET request to "/save/local",
        -- check local state for a value based on session cookei
        |> getSyncState paths.local getSessionLocal
        -- Synchronously handle POST request to "/save/local",
        -- save value to local state based on session cookei
        |> postSyncState paths.local postSessionLocal
        -- Asynchronously handle GET request to "/save/db",
        -- check disk state for a value based on session cookei
        |> get paths.db getSessionDB
        -- Asynchronously handle POST request to "/save/db",
        -- save value to disk state based on session cookei
        |> post paths.db postSessionDB
        -- Asynchronously handle GET request to "/"
        -- Reply with "./public/index.html"
        |> get (p "/") getIndex
        -- statically serve files from "./public/"
        |> static any "./public/"
        -- Asynchronously redirect "/index" to "/"
        |> get (p "/index") (\ _ -> succeed << Redirect <| "/")
        -- Fallback, match to any path, take entire unhandled address,
        -- Reply with a string value which specifies that the path does not exist
        |> getSync str getInvalid   
Enter fullscreen mode Exit fullscreen mode

The text below is a shortened version of the original documentation published as a readme file of the project. It has a bit more details regarding the API and architecture of the project. Also, there are seed and demo projects to help you develop your application with the Board.

Motivation

Nowadays, almost every cloud platform offers possibilities for the seamless deployment of Node.js applications. The goal of the project is to combine deployment capabilities of Node.js and the safety of statically typed purely functional languages for the rapid development of a small micro-service application.

The main reasons Elm was chosen over GHCJS or PureScript are a steeper learning curve, excellent documentation, active community and build-in state management system. It also has no way to be used on a back-end, so it was rather cool to be first.

Implementation

Board was partly inspired by Spock, Express and other Sinatra like frameworks. Typesafe URL parsing planned to be one of the main features. The parsing engine was moved to a separate project available for everybody at Elm package manager as Pathfinder.

Router

It is the fundamental part of an application. Basically, it is just a function that defines the process of turning Request object into Response one. Request object describes essential information about the incoming inquiry.

Response is a representation of the server reply. The object is matched with a client by a request's id. Board can create an initial response for a Request. Shared.getResponse function constructs an empty response with an id of the provided request record.

makeStringResponse : Request a -> String -> Response b
makeStringResponse req text = 
    let 
        res = Shared.getResponse req
    in
        { res
        | content = Text "text/plain" text
        , id = req.id
        , status = ok
        } 
Enter fullscreen mode Exit fullscreen mode

Routing combinators

A router function is composed out of several custom request handling functions and by the routing combinators. The combinators are represented by a function that takes a path description as a first argument and handler for the specified address as the second one. Pathfinder is utilized to describe the URL, which triggers the path handler. The handling function is responsible for turning the request record and params extracted by the URL parsers into one of the three possible results:

  • Redirection to a new path
  • Replying with an appropriate response record
  • Passing the request further, possible with attached cargo

router =
  logger "Request"
    |> getSyncState p "/local" getSession


getSession (param, req) state =
    let 
        sessionTag = 
            getSessionTag req state
        sessionValue = 
            Dict.get sessionTag state
    in
       ( state
       , withDefault "No value for your session" sessionValue
            |> makeStringResponse req
            |> Reply
       ) 
Enter fullscreen mode Exit fullscreen mode

use routing combinators are capable of handling any types of HTTPS requests while get, post, put and delete are only working with correspondent HTTP methods.

Stateless and Stateful

State management is not a trivial task for a purely functional application. Board utilizes Elm’s architecture to provide handy tooling for accessing and modifying an application state.

...
  |> getSyncState any (\ (param, req) state -> (state, Redirect "/index.html") )  
Enter fullscreen mode Exit fullscreen mode

There is a particular type of rout handlers capable of providing access to the state of the application. The access is granted by transactions. Instead of returning the AnswerValue record itself, a route handler attached by such a routing combinator returns a transaction from a current state to tuple which composes state and AnswerValue. State-less combinators are not capable to access current state.

...
  |> getSync any (\ (param, req) -> Redirect "/index.html")   
Enter fullscreen mode Exit fullscreen mode

Sync and Async

On another hand, the route handler can be represented by an atomic as well as an asynchronous operation. Atomic operations are usually related to some processing of request’s data, parsing or local state modifications. Async ones are typically associated with file handling, database manipulations or communication with third-party services. Task handles the asynchronous nature of the actions.

Synchronous processing usually sequentiality handles the request and immediately returns a correspondent response.

getSessionLocal : ( b , Request a) -> State -> ( State, AnswerValue value state error )
getSessionLocal (param, req) state =
    ( state 
    , getSession param req state
    )
Enter fullscreen mode Exit fullscreen mode

Async processing is usually caused by awaiting an asynchronous action performed based on a handled request.

getSessionDB : ( b , Request a) -> Task x (AnswerValue value state error)
getSessionDB (param, req) = 
    readDict
        |> map (getSession param req)
Enter fullscreen mode Exit fullscreen mode

Initial router

Routing combinators are responsible for combining an existing router with a new path handler. So, therefore, a first router is needed. It is represented by any function which satisfies the following signature Request String -> Mode String (Answer String State String). The function is going to be called once for every request. It might execute some parsing or authentication actions. Result of the actions can be propagated by a Cargo property of a Request record.

routerWithLogger =
    -- Default router prints requests with specified prefix as default actions
    logger "Request"


router =
    -- No default actions at an empty router
    empty
Enter fullscreen mode Exit fullscreen mode

Node.js server

Board uses calls to native Node.js API to establish the HTTP/HTTPS connection. An incoming Request object is processed and converted into a Request record exposed to Elm code. Response object is placed on a Map. The object is popped up from the Map based on a Response record created as output of Elm code. From time to time, the Map is cleaned out of Responses, which are older than the timeout.

File handling

File handling is implemented via a very simple library based on Node.js fs. Practically it contains functions to read, write and parse files. Files are represented by a higher-order function which takes a function from Node.js Buffer to arbitrary Elm type. The data itself is enclosed inside of closure so that it is not directly accessible at the Elm side without proper handling.

getFile : String -> ( b, Request a ) -> Task String (AnswerValue value state error)
getFile path (param, req)  =
    let 
        res = getResponse req
        makeResponse file =
            { res
            | content = Data (File.getContentType path) file
            , id = req.id
            , status = ok
            } 
    in
        path
            |> File.read
            |> map makeResponse
            |> map Reply
Enter fullscreen mode Exit fullscreen mode

There are two standard parsers: string and dict. Also, there are functions specialized in the encoding of Elm types to File: fromString and fromDict.

saveSessions : ( State, a ) -> Task String a
saveSessions (state, res) =
    state 
        |> File.fromDict
        |> File.write "public/db.json"
        |> map (\ _ -> res)
Enter fullscreen mode Exit fullscreen mode

The last but not least there is getContentType function, which returns content-type based on the file name. The function is utilized by static.

router =
    -- No default actions at empty router
    empty
        -- statically serve files from "./public/"
        |> static any "./public/"  
Enter fullscreen mode Exit fullscreen mode

Known limitations

It was an experimental project which was mainly done to investigate the possibility of adapting Elm architecture for back-end development at the same time as improving my knowledge of Node.js APIs.

Due to the nature of Elm architecture and Node.js, the system in a current condition is not capable of handling multi-threaded applications. Implementation of such a functionality is way beyond the scope of the project right now.

The project was started right before Elm 0.19 was released. The version of Elm dramatically changed the way native code is handled. Native code is entirely forbidden for third-party libraries since the release. Therefore the project didn’t get any support from the mainstream Elm community, and it will never be available at the package manager. Also, due to dramatic changes in the infrastructure of Elm, the 0.18 and older versions might be eventually discontinued.

Since it is essentially a single person pet project, there is a significant lack of testing, especially the production one. I will personally be happy to see the project based on the library, but you have to be aware of risks.

The micro-framework currently supports only HTTP and HTTPS. Sockets are out of scope.

Some future development is required at following directions: authentication, cookies and file handling.

Future plans

Due to recent changes in a platform, the project ended up to be just a proof of concept. Since it is not possible to update it for the newer version of Elm, it is also not possible to publish it in Elm package manager, because of policy regarding native modules which are an essential part of the system in this case.

PureScript seems to be the best option for migration of the project, but lack of Elm Architecture would require a reconsideration of the state management system. At the same time, it will open an opportunity to utilize and advantages of PureScript type system like Type Classes and Higher-kind types. It might be especially useful for the implementation of the URL parsing eDSL.

Another viable opportunity is GHCJS, but in my point of view, it is overkill since there are many brilliant back-end frameworks for Haskell and there is no need to mess around with such a complication as a translation to JS and utilization of Node.js infrastructure.

Top comments (2)

Collapse
 
shimmer profile image
Brian Berns • Edited

This looks cool. The combinator-based approach reminds me of F#'s Suave.IO web server library.

Collapse
 
airtucha profile image
Alexey Tukalo

Thanks a lot! I don't have much experience with F#, but it feels like now I have to take a look. It was actually inspired by Express and Sinatra because I am most familiar with them.