2020-03-21, 10 min, by Josef Erben

Sihl: Full Stack Web Development in ReasonML

Sihl is a proof of concept of a web framework for Reason. It aims to deal with infrastructure similarly to frameworks like Rails and Django, so we can focus on the essential complexity of our web app.

The main goal is to turn as many run-time bugs into compile-time bugs as possible, both on the backend and the frontend.

In this blog post, we introduce Sihl and show an example app that was made with it. In the end, we give a short report of using Reason full-stack in a project. If you are familiar with Reason/BuckleScript/OCaml, please forgive the simplifications we make, but our goal is to make it accessible to a broad audience. This is not an excuse for errors, so if you find any, just drop them in the comments.

Example Project: Issue Management App

Before we properly introduce Sihl, we show the example application for those of you who just want to see the code.

It is deployed here and the code can be found here.

What is Reason?

ReasonML Logo Reason is a language that compiles to JavaScript. In the official Reason documentation it is explained very well what Reason is and why we choose it as the language for the framework.

Strong compile-time guarantees

Due to reasons (pun avoided successfully) listed in the documentation, using Reason allows us to catch many bugs at compile-time that normally would have been run-time bugs. There are bugs that you will typically catch with Reason that you won’t catch with other statically typed languages like TypeScript.

Let’s just say the compiler is very strict and it takes some effort to make it stop yelling at us. But once it’s happy, chances are that we’ll be happy too, because we eliminated some bugs that otherwise would’ve waited for us after starting the app.

React bindings

Yes, we are listing bindings to a JavaScript frontend framework as one of the main reasons to use a language. This is how much we like it. ReasonReact is used in Facebook’s Messenger, the bindings are battle-tested. With the provided hooks React.useState and React.useReducer, there is built-in state management and we don’t need to pull in redux. ReasonReactRouter uses pattern matching to do routing, which makes the API simple and elegant, with no react-router needed.

And then there is the fact that all your JSX, props and state are statically checked without annotating every type!

If you are just interested in a testable, working, real-world example of a ReasonReact app, go this way.

Sihl

Reason on its own is a useful and fun language, but there is a long way to a real-world web app. Sihl aims to take care of some of the boring parts of web development, so we can focus on the important things that make our customers happy.

This is what Sihl does for us

  • HTTP: Type-safe declarative routes
  • Structure & Lifecycle: We develop Sihl apps, compose them to projects and throw them at Sihl to manage
  • Migrations: We create database migrations per app, Sihl takes care of applying them
  • Admin UI: Our admins love us for the UIs we give them using the Admin UI React API
  • Testing: Seeding data before and cleaning up after integration tests
  • CLI: Creating CLI commands per app yarn sihl <command> <param1> <parma2> …
  • Full Stack: Sharing business logic, data, decoders and encoders with the frontend
  • Async/await: Writing non-blocking code without the noise of nesting Promises (or, god forbid, the Callback Hell)

HTTP

HTTP endpoints can be expressed concisely in a type-safe manner thanks to the beautiful endpoint abstraction of Serbet.

It is a Reason module that encapsulates an HTTP GET endpoint returning a list of users as JSON.

module GetUsers = {
  [@decco]
  type body_out = list(Model.User.t);

  let endpoint = (root, database) =>
    Sihl.Core.Http.dbEndpoint({
      database,
      verb: GET,
      path: {j|/$root/users/|j},
      handler: (conn, req) => {
        open! Sihl.Core.Http.Endpoint;
        let%Async token = Sihl.Core.Http.requireAuthorizationToken(req);
        let%Async user = Service.User.authenticate(conn, token);
        let%Async users = Service.User.getAll((conn, user));
        let response =
          users |> Sihl.Core.Db.Repo.Result.rows |> body_out_encode;
        Async.async @@ Sihl.Core.Http.Endpoint.OkJson(response);
      },
    });
};

An endpoint does not only contain the request handler but also contains definitions of valid requests as types. Decoding a request body, parameters or query strings is done using the amazing decoder library decco.

Invalid requests are handled by Sihl, it will respond with Bad Request 400 in case decoding fails.

Structure & Life Cycle

One of the major concerns of Sihl is structuring the web project. This part is heavily inspired by Django’s applications. A Sihl project consists of multiple Sihl apps, each of which solve one particular problem, either in business or in infrastructure. It is self-contained and it could be deployed on its own.

This is the file structure of an app:

.
β”œβ”€β”€ bsconfig.json
β”œβ”€β”€ package.json
β”œβ”€β”€ src
β”‚   β”œβ”€β”€ AdminUi.re
β”‚   β”œβ”€β”€ App.re
β”‚   β”œβ”€β”€ client
β”‚   β”‚   β”œβ”€β”€ <ReasonReact files>
β”‚   β”‚   β”œβ”€β”€ <ReasonReact files>
β”‚   β”œβ”€β”€ Migrations.re
β”‚   β”œβ”€β”€ Model.re
β”‚   β”œβ”€β”€ Repository.re
β”‚   β”œβ”€β”€ Routes.re
β”‚   β”œβ”€β”€ Seeds.re
β”‚   β”œβ”€β”€ Service.re
β”‚   └── Sihl.re
β”œβ”€β”€ static
β”‚   β”œβ”€β”€ index.html
β”‚   └── style.css
β”œβ”€β”€ __tests__
β”‚   β”œβ”€β”€ integration
β”‚   β”‚   └── IssueIntegrationTest.re
β”‚   └── unit
β”‚       └── ClientBoardPageTest.re
└── yarn.lock

An app comprises of models, services, repositories, routes, migrations, configurations and commands which are listed in App.re.

App.re contains a description of an app. Sihl can take this description and run it by applying the migrations, starting the webserver and mounting the routes.


let name = "Issue Management App";
let namespace = "issues";

let routes = database => [
  Routes.GetBoardsByUser.endpoint(namespace, database),
  Routes.GetIssuesByBoard.endpoint(namespace, database),
  Routes.AddBoard.endpoint(namespace, database),
  Routes.AddIssue.endpoint(namespace, database),
  Routes.CompleteIssue.endpoint(namespace, database),
  Routes.AdminUi.Issues.endpoint(namespace, database),
  Routes.AdminUi.Boards.endpoint(namespace, database),
  Routes.Client.Asset.endpoint(),
  Routes.Client.App.endpoint(),
];

let app = () =>
  Sihl.Core.Main.App.make(
    ~name,
    ~namespace,
    ~routes,
    ~clean=[Repository.Issue.Clean.run, Repository.Board.Clean.run],
    ~migration=Migrations.MariaDb.make(~namespace),
    ~commands=[],
  );

The only difference between a project and an app is, that the project has a Main.re file that lists all the apps it contains.

let apps = [Sihl.Users.App.app(), App.app()];

Sihl.Core.Main.Cli.execute(apps, Node.Process.argv);

Migrations

Sihl doesn’t come with an ORM or query builders, persistence is entirely up to us. At the moment, there is no schema or migration generation based on models either.

Sihl provides hooks to plug-in our own migrations, so that the schema versions are kept up-to-date and for CLI commands to be used.

Admin UI

One of the ambitious goals is to provide a similar out-of-the-box Admin UI as Django. This is tricky, due to the restrictions in metaprogramming; there is no way to get the list of fields of a type at run-time. We will either get that metadata at compile-time or we will require the developer to define it manually. The latter approach might be a bit verbose and it could slow down development.

At the moment, the pages in the Admin UI have to be built manually using React. Each app provides its own Admin UI pages and Sihl merges them all into one Admin app.

Maybe an integration of react-admin makes sense, where the CRUD UI is generated based on user-provided metadata.

Testing

Testing is done using Jest with the bs-jest bindings. Integration tests use the seeding mechanism and the test harness features provided by Sihl.

By calling Integration.setupHarness([App.app()]), you instruct Sihl to apply the migrations and start the webserver before running the tests, and to clean up the database after each test. Following is an example test that registers a user and fetches /users/me.

include Sihl.Core.Test;
Integration.setupHarness([App.app([])]);
open Jest;

let baseUrl = "http://localhost:3000/users";
let adminBaseUrl = "http://localhost:3000/admin/users";

Expect.(
  testPromise("User registers, logs in and fetches own user", () => {
    let body = {|
       {
         "email": "[email protected]",
         "username": "foobar",
         "password": "123",
         "givenName": "Foo",
         "familyName": "Bar",
         "phone": "123"
       }
       |};
    let%Async _ = Sihl.Core.Main.Manager.seed(Seeds.admin);
    let%Async _ =
      Fetch.fetchWithInit(
        baseUrl ++ "/register/",
        Fetch.RequestInit.make(
          ~method_=Post,
          ~body=Fetch.BodyInit.make(body),
          (),
        ),
      );
    let%Async loginResponse =
      Fetch.fetch(baseUrl ++ "/[email protected]&password=123");
    let%Async tokenJson = Fetch.Response.json(loginResponse);
    let Routes.Login.{token} =
      tokenJson |> Routes.Login.body_out_decode |> Belt.Result.getExn;
    let%Async usersResponse =
      Fetch.fetchWithInit(
        baseUrl ++ "/users/me/",
        Fetch.RequestInit.make(
          ~method_=Get,
          ~headers=
            Fetch.HeadersInit.make({"authorization": "Bearer " ++ token}),
          (),
        ),
      );
    let%Async usersJson = Fetch.Response.json(usersResponse);
    let {Model.User.email} =
      usersJson |> Model.User.t_decode |> Belt.Result.getExn;

    email |> expect |> toBe("[email protected]") |> Sihl.Core.Async.async;
  })
);

Line 20 let%Async _ = Sihl.Core.Main.Manager.seed(Seeds.admin); applies the seed Seeds.admin which is a function that creates an admin. A seed is just a function that takes a database connection and returns a promise. Seeds.re contains all the seeds per app.

let admin = conn =>
  Service.User.createAdmin(
    conn,
    ~email="[email protected]",
    ~username="admin",
    ~password="password",
    ~givenName="Admin",
    ~familyName="Admin",
  );

CLI

This feature was inspired by Django as well. The idea is that each app provides its own CLI commands. Those commands can be called using yarn sihl <command> <param1> <param2>.

At the moment, there are just two commands: yarn sihl start to start the project and yarn sihl version to get the Sihl version.

Later on, the commands will be namespaced using the app name to avoid collisions.

Full Stack

This is not a feature of Sihl but the result of hard work put into the Reason/BuckleScript/OCaml tooling to target JavaScript. This allows us to share business logic, decoders, encoders and data between the frontend client and the backend business logic layer with type-safety.

Please check out the example project that contains a ReasonReact app that shares the models with the backend.

Async/await

By using async/await in JavaScript, we can write non-blocking code without chaining Promises. Thanks to the work of Serbet and bs-let, we can have something similar in Reason. let%Async β€œun-nests” promise chains which reduces the noise. It is easier to read the code.

let handler = (conn, req) => {
  let%Async token = Sihl.Core.Http.requireAuthorizationToken(req);
  let%Async user = Sihl.Users.User.authenticate(conn, token);
  let%Async {userId} = req.requireParams(params_decode);
  let%Async boards = Service.Board.getAllByUser((conn, user), ~userId);
  let response = boards |> Sihl.Core.Db.Repo.Result.rows |> body_out_encode;
  Async.async @@ Sihl.Core.Http.Endpoint.OkJson(response);
};

Conclusion

The project is a proof of concept, and the APIs will change drastically.

Nevertheless, parts of it are used in production with great success. Full Stack Reason changed the way we developed, reviewed and deployed our code.

Compared to our previous experience with Full Stack TypeScript (with NodeJS, Express and React), we were much more often fighting the compiler. The amount of bugs we caught with Reason at compile-time was intuitively very high. This reduced the amount of tests to be written, too! Over the course of 5 months of full-time development and continuous deployments to the live environment, there were just 2 bugs that reached the customer.

Reviewing ReasonReact code was also drastically different from reviewing React with JavaScript or TypeScript. We knew that the markup is correct, we knew that we handled all cases in the reducers (thanks to comprehensive pattern match checks), and we knew that our props, state, and context had the correct types. So during the reviews we could focus on high-level logic, integration of pages and routing.

We were able to build an MVP in a month, which we deployed live for customers to use. The subsequent development was all done on the live system without a staging environment. Thanks to Reason we were able to add incremental improvements which made our customer happy.

If this project sounds interesting to you, make sure to follow the project and star the repo.

If you would like to hire us to work on (functional) web projects, don’t hesitate to contact us.