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.
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.
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.
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.
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.
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.
yarn sihl <command> <param1> <parma2> β¦
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.
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);
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.
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 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",
);
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.
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.
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);
};
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.