A Lightweight OCaml Webapp Tutorial
This tutorial aims to guide readers familiar with OCaml along one course to a backend for a webapp. The app is lightweight in that it doesn’t take much code to define and in that it probably shouldn’t be used for any heavy, industrial applications.
The app implements two kinds of functionality:
- An embellished echo server, responding to path parameters
- An interface to a rudimentary database of author excerpts
The tutorial covers the following topics:
- Setting up and configuring the project
- Routing requests (including POSTed form data)
- Generating HTML dynamically
- Interfacing with PostgreSQL
(The OCaml ecosystem also has great support for the frontend, thanks to
Js_of_ocaml
and Bucklescript
, but we won’t be covering that here.)
Feedback, improvements, and corrections are most welcome!
Table of Contents
1 Tips on Reading this Tutorial
- This is a more “advanced” tutorial, covering a wide range of functionality. For a very basic intro using a simple echo server, see the README.md in the Opium repo.
- All file paths are given relative to the project’s root directory, unless explicitly noted otherwise.
- This repository contains the complete code for the app. The tutorial consists of a tour of the source code, with some notes on setup. So I encourage you to clone the app and play with the code as you read.
- For quick reference to code examples, see the Index of Listings
- Skip or skim “Explanation of the code” sections if the code is clear to you.
- Skip to the sections that are useful to you.
2 Clone This Repository
The best way to read this tutorial is to clone the repository
git clone https://gitlab.com/shonfeder/ocaml_webapp
And play with the code as you read along.
(If you’re org-mode user, you may even prefer to just read the ./tutorial/tutorial.org file locally.)
3 Setup
3.1 Dependencies
We will be using the following OCaml libraries to build the app:
- Opium: A Sinatra-like web toolkit
- Caqti: A library for interfacing with PostgreSQL
- ppx_rapper: A syntax extension to ease writing sql queries for
Caqti
- TyXML: A library for generating correct HTML
- Logs: A logging library
- Lwt: A concurency library
- Core: A (standard) standard library alternative
There are many other excellent and powerful web libraries we won’t be using. See the OCamlverse page on Web and Networking for a catalog.
3.2 Prerequisites
This section is for readers who don’t currently have a well configured OCaml setup in place. Skip to Configuration if you’re already setup with the modern suite of tools.
3.2.1 Install and initialize opam
opam
is the OCaml package manager.
To install opam, run
sh <(curl -sL https://raw.githubusercontent.com/ocaml/opam/master/shell/install.sh)
Initialize opam and build your main working environment with
opam init # Install any dependencies called for # Answer yes to the prompts to configure your shell environment opam switch 4.08.1 # Sets the system ocaml compiler to use ocaml 4.08.1 # Use a different version at your own discretion
3.2.2 Install dune
Dune is the standard build system for OCaml. We’ll need it to initialize our project below.
opam install dune
3.2.3 Install editor support and initialize your configurations
For the basic tooling, run
opam install user-setup merlin ocamlformat ocp-indent opam user-setup install
- Emacs
Install the OCaml mode for emacs
opam install tuareg
If you aren’t already an emacs user but want to give it a spin, I highly recommend the spacemacs distribution for a beginner friendly approach.
I use doom-emacs, and find the IDE experience phenomenal.
- For Vim or VSCode
3.2.4 Install PostgreSQL
This tutorial currently1 uses postgres, and you’ll need postgres installed to run the app successfully.
If you don’t already have postgres set up, see the installation instructions from postgresql.org and/or the docs on installing postgres for your distro or OS.
Make sure you also have configured your user with access to create and update databases. The Arch wiki has a short section on creating your first database and user.
3.3 Configuration
This section is for users who aren’t very familiar with configuring an OCaml
project. Skip to Create an opam Switch if you’re familiar with wrangling dune
and opam
.
We’ll walk through the configuration2 of the project in this section. However, if you cloned the repository, as advised, I encourage you to skip to the next section and then refer back here later as needed, and for reference when you’re making your own project.
3.3.1 Project initialization
The basic skeleton was generated with the dune init
subcommand:
dune init proj ocaml_webapp \ --libs core,opium,caqti,caqti-driver-postgresql,caqti-lwt,tyxml,lwt.unix \ --ppx ppx_rapper
This creates a new directory named ocaml_webapp
, with the following
subdirectories corresponding to self-contained components:
lib
: The library component, where all the business logic should livebin
: The executable component, this will be a client of ourlib
test
: You can guess (we won’t be using this in our tutorial)
Each directory has it’s own dune
file configuring the component, (which
includes a libraries
stanza declaring the component’s dependencies).
3.3.2 Configuration of the package and its dependencies
We use dune
to generate the opam
package configuration. We always want to
configure a package for our project so that dependencies are managed via
configuration as code throughout.
The package is configured in ./dune-project
:
(lang dune 2.0) (generate_opam_files true) (name ocaml_webapp) (version 0.1.0) (authors "Shon Feder") (license MIT) ; TODO (source (uri "git+https://gitlab.com/shonfeder/ocaml_webapp.git")) ; TODO (maintainers "shon.feder@gmail.com") ; TODO (homepage "https://gitlab.com/shonfeder/ocaml_webapp") ; TODO (bug_reports "https://gitlab.com/shonfeder/ocaml_webapp/issues") ; TODO (documentation "https://shonfeder.gitlab.io/ocaml_webapp/") ; TODO (package (name ocaml_webapp) (synopsis "A minimal example of a lightweight webapp in OCaml") ; TODO (description "A minimal webapp using Opium, Catqi, and tyXML") ; TODO (depends ;; General system dependencies (dune (>= 2)) (ocaml (>= 4.08.0)) ;; Standard library replacement (core (>= v0.12.2)) ;; Web toolkit (opium (>= 0.17.1)) ;; Database interface (caqti (>= 1.2.3)) (caqti-lwt (>= 1.2.0)) (caqti-driver-postgresql (>= 1.2.4)) (ppx_rapper (>= 2.0.0)) ;; HTML generation (tyxml (>= 4.3.0)) ;; Logging (logs (>= 0.7.0)) ;; Dev dependencies (utop :dev) (merlin :dev) (ocamlformat :dev) (ocp-indent :dev) (tuareg :dev) ;; rm if not using emacs ))
The fields that you should customize in your own app are marked with TODOs, and their meaning should be straightforward. See the dune docs if you want more details on this configuration.
3.3.3 The Makefile
These make
rules help to expedite and standardize some common development
tasks and commands. (If you prefer to manage these manually, you can use the
makefile as a recipe book.)
# The name of your project (as given in the dune-project file) # TODO project_name = ocaml_webapp # The opam package configuration file opam_file = $(project_name).opam .PHONY: deps run run-debug migrate rollback # Alis to update the opam file and install the needed deps deps: $(opam_file) # Build and run the app run: dune exec $(project_name) # Build and run the app with Opium's internal debug messages visible run-debug: dune exec $(project_name) -- --debug # Run the database migrations defined in migrate/migrate.ml migrate: dune exec migrate_ocaml_webapp # Run the database rollback defined in migrate/rollback.ml rollback: dune exec rollback_ocaml_webapp # Update the package dependencies when new deps are added to dune-project $(opam_file): dune-project -dune build @install # Update the $(project_name).opam file -git add $(opam_file) # opam uses the state of master for it updates -git commit $(opam_file) -m "Updating package dependencies" opam install . --deps-only # Install the new dependencies
3.4 Create an opam Switch
An opam
“switch” is a sandboxed ocaml environment.
3.4.1 A fresh opam switch for the project
Create a new opam
switch for the project with:
# From within the project root directory opam switch create . 4.08.1 --deps-only # I recommend 4.08.1 because all basic tools are compatible with it as of 2019/12/30 # if this should need updating, please make an issue or PR
Developing projects in their own switch helps to ensure smoother deployment and guards against dependency conflicts arising between unrelated projects on your local system.
To verify that the switch has been created and that you are now working in the sandbox, you can run
opam switch # →/home/you/path/to/ocaml_webapp ocaml-base-compiler.4.08.1 /home/you/path/to/ocaml_webapp
If you do not see the switch associated with your project directory indicated by
a →
, then something has gone wrong.
3.4.2 Install dune in the switch
We installed dune previously at the system level, but we’ll need it in our project sandbox too:
opam install dune
3.4.3 Install and update the project dependencies
Run
make deps # install all dependencies
(If you opted not to use the suggested Makefile
, manually execute the deps
rule defined in the Makefile)
NOTE: This assumes you are working inside a git repository If you did not
clone this repo, then make sure to git init
prior to installing the deps.
3.4.4 Confirm that the app skeleton can build
dune build
If no errors are reported, then setup is complete!
3.5 Create the Postgres database
Assuming you have postgresql installed, you can use the createdb
utility to
create a new database for the app:
createdb ocaml_webapp # Replace 'ocaml_webapp' with your app's name if you're working with custom code
Then run the database migration to create the needed tables
make migrate
3.5.1 Troubleshooting problems with the database port
The app is configured to look for postgres at the localhost ip on its default
port of 5432
. If you have trouble connecting, ensure that the database is
configured to match this configuration, or update the configuration in
./lib/db.ml to match your postgres setup:
11: (** Configuration of the connection *) 12: let url = "localhost" 13: let port = 5432 14: let database = "ocaml_webapp"
4 Tour of the Application
4.1 The Executable
We’ll define our app’s main executable entrypoint in the aptly named
./bin/main.ml
:
1: open Opium.Std 2: 3: open Ocaml_webapp 4: 5: let static = 6: Middleware.static 7: ~local_path:"./static" 8: ~uri_prefix:"/static" 9: () 10: 11: (** Build the Opium app *) 12: let app : Opium.App.t = 13: App.empty 14: |> App.cmd_name "Ocaml Webapp Tutorial" 15: |> middleware static 16: |> Db.middleware 17: |> Route.add_routes 18: 19: let log_level = Some Logs.Debug 20: 21: (** Configure the logger *) 22: let set_logger () = 23: Logs.set_reporter (Logs_fmt.reporter ()); 24: Logs.set_level log_level 25: 26: (** Run the application *) 27: let () = 28: set_logger (); 29: (* run_command' generates a CLI that configures a deferred run of the app *) 30: match App.run_command' app with 31: (* The deferred unit signals the deferred execution of the app *) 32: | `Ok (app : unit Lwt.t ) -> Lwt_main.run app 33: | `Error -> exit 1 34: | `Not_running -> exit 0
Run the app with
make run
and visit http://localhost:3000/.
4.1.1 Explanation of the code
From the bottom up…
- Execution
The first thing we do is set up logging with the
set_logger
function3. Doing this first ensures that any important log messages that may be generated during subsequent actions will be recorded.App.run_command' app
generates a commandline interface for launching our applicationapp
. It returns a value of typeunit Lwt.t
, signifying the deferred result of running the program.You can read the docs for the generated command line interface by running
dune exec ocaml_webapp -- --help
We pass the deferred result to
Lwt_main.run
, which kicks of theLwt
scheduler, and runs our app (see the Lwt_main docs for more info). - Setting the logger
The
set_logger
function itself merely uses the defaultlogs
log reporter. It sets thelog_level
, which we’ve hard coded toLogs.Debug
. We use theLwt
binding operator to sequence our actions here, just as we do in therun
function.Note that for this logging configuration to work, we rely on the library dependencies
logs
,logs.fmt
, andlogs.lwt
declared in ./bin/dune:9: logs ;; The logs library 10: logs.fmt ;; The logs format helper 11: logs.lwt ;; The logs concurrency helper
To see the debug logging output (very useful for tracking down routing problems) run the app with
make run-debug
This will output all the incoming and outgoing HTTP requests to your terminal.
NOTE: The
--debug
flag used in the invocation of Opium here only affects the internal logging from the Opium library. It is thelog_level
we set that effects the output level from the logger. - Building the app
let app = ...
defines our application. Applications are built up by pushing a baseApp.empty
through a pipeline of functions of typeApp.builder
.App.builder
is just a synonym for functions of typeApp.t -> App.t
, which take in an application, add something to it, and pass along the new application. In this case, we build the app with the follow steps:- Set the name for the command line interface with
App.cmd_name
- Add the middleware to serve static files
- Add the database connection to the environment with the
Db.middleware
function - Add all of the route handlers with
Route.add_routes
.
The helper
static
just configures a piece of middleware to serve static files, using Opium’sMiddleware.static
. It serves the contents of the ./static directory at the"/static"
route. - Set the name for the command line interface with
4.2 Simple Route Handlers
The Route.add_routes
of ./bin/main.ml adds all of our route handlers to the
app. This is defined in ./lib/route.ml
:
72: let routes = 73: [ root 74: ; hello 75: ; hello_fallback 76: ; excerpts 77: ; get_excerpts_add 78: ; post_excerpts_add 79: ; excerpts_by_author 80: ; excerpts 81: ] 82: 83: let add_routes app = 84: Core.List.fold ~f:(fun app route -> route app) ~init:app routes
All of our routes are defined as App.builder
functions, and listed in the
routes
list.
Let’s look at the simplest handlers, that just serve a basic page and act as an echo server:
1: open Opium.Std 2: (* HTML generation library *) 3: open Tyxml 4: 5: (** The route handlers for our app *) 6: 7: (** Defines a handler that replies to GET requests at the root endpoint *) 8: let root = get "/" 9: begin fun _req -> 10: respond' @@ 11: Content.welcome_page 12: end 13: 14: (** Defines a handler that takes a path parameter from the route *) 15: let hello = get "/hello/:lang" 16: begin fun req -> 17: let lang = param req "lang" in 18: respond' @@ 19: Content.hello_page lang 20: end 21: 22: (** Fallback handler in case the endpoint is called without a language parameter *) 23: let hello_fallback = get "/hello" 24: begin fun _req -> 25: respond' @@ 26: Content.basic_page Html.[p [txt "Hiya"]] 27: end
4.2.1 Explanation of the code
- Responding to requests
Each function is defined in terms of a route combinator that takes a string encoding the route and a handler function. The handler function takes an incoming request (
req
) to an outgoing response.The
root
andhello_fallback
handlers don’t use the request. Instead, they use respond’ to reply with the relevant page defined in theContent
module. The'
on therespond'
function indicates that the respone is a deferredLwt.t
value. We’ll use this for all our respones, andLwt
will handle all the concurrency for us.Note that the
hello_fallback
handler offers the first appearance of HTML generation:Html.[p [txt "Hiya"]]
invokes thep
andtxt
functions from theTyxml
’sHtml
module to generate a paragraph HTML element holding the text"Hiya"
. We will explore this deeper in the section on Content Generation. - Handling path parameters
The
hello
function handles routes that include a segment after the"hello"
. The:lang
component of the path will cause a"lang"
parameter to be added to thereq
. This parameter will hold a string recording the value of this segment of the path for any incoming request that matches the pattern."/hello/Hindi"
matches this pattern but"/hello/foo/bar"
and"/hello"
do not.Here, we just pass the lang parameter on to our page generation function
Content.hello_page
.
4.3 Content Generation
All of the route handlers call functions from the Content
module. This is
where we’ve collected all the functions responsible for generating HTML. I’ll
walk through some highlights of the code, but see the Tyxml documentation for
more information and instruction on how to use it’s Html
module (there is also
a ppx syntax extension letting you write HTML with embedded OCaml).
There are other options for HTML templating, so if you prefer, you may want to check out ocaml-mustache or ecaml.
4.3.1 Basics of Tyxml as Seen in the <head>
Element
For the sake of a deeper dive into a short piece of HTML generating code, consider the head element we define for use in all the following content:
1: open Core 2: open Tyxml 3: 4: (** A <head> component shared by all pages *) 5: let default_head = 6: let open Html in 7: head 8: (title (txt "OCaml Webapp Tutorial")) 9: [ meta ~a:[a_charset "UTF-8"] () 10: ; link ~rel:[`Stylesheet] ~href:"/static/style.css" () ]
- Explanation of the code
Tyxml.Html
aims to provide type-safe combinators for every valid HTML element. The name of the combinator is generally the same as the tag for the element itself. Most combinators take an optional named~a
argument that accepts a list of attributes, and a required argument with a list of values of typeTyxml.Html.elt
which specify the element’s inner HTML.In the
default_head
function, we seehead
, which generates<head>...</head>
elements. It is an exception to the general pattern of element combinators: since every well formed head element should have a title,head
takes a requiredtitle
argument, and then the usual list of other elements. Thetitle
element itself takes a terminaltxt
expression.meta
takes a()
instead of a list of elements. This is because it is a terminal element. Themeta
element shows an example of setting attributes:Txyml.Html
attributes combinators all begin with thea_
prefix. In this case, we declare the charset attribute for our HTML documents.The above code generates the following HTML:
<head> <title>OCaml Webapp Tutorial</title> <meta charset="UTF-8"/> <link rel="stylesheet" href="/static/style.css"/> </head>
4.3.2 Generating HTML for Use by Opium
opium
has no understanding of Tyxml
: when we reply with HTML, opium
just
expects the HTML to be represented as a string. Thus, we’ll need to render
Tyxml
’s well-typed HTML AST into a string before responding to requests. In
the following code, we do that conversation in the basic_page
function:
11: (** The basic page layout, emitted as an [`Html string] which Opium can use as a 12: response *) 13: let basic_page content = 14: let raw_html = 15: let open Html in 16: html default_head (body content) 17: |> Format.asprintf "%a" (Html.pp ()) 18: in 19: `Html raw_html
- Explanation of the code
This function is responsible for the following preparation of its
content
:- Wrap the
content
in a<body>
tag - Construct an
html
element with thedefault_head
- Serialize the AST of type
_ Tyxml.Html.elt
into a well formatted string - Wrap the
raw_html
in the`Html
polymorphic variant tag expected by opium
All the HTML we construct for our app will ultimately go trough
basic_page
prior to becoming part of anopium
response. - Wrap the
4.3.3 Parametric Templates
Let’s look at a template which takes a parameter, and see how this parameter is threaded through from the route handler.
Consider the hello
handler again
14: (** Defines a handler that takes a path parameter from the route *) 15: let hello = get "/hello/:lang" 16: begin fun req -> 17: let lang = param req "lang" in 18: respond' @@ 19: Content.hello_page lang 20: end
It retrieves the value supplied as a path parameter in the :lang
position from
the request, req
, and forwards it to the Content.hello_page
function. This
function will then generate our HTML, which we send back as our response via
respond'
Content.hello_page
simply pattern matches on the language supplied and generates
a page with an appropriate greeting:
41: let hello_page lang = 42: let greeting = match lang with 43: | "中文" -> "你好,世界!" 44: | "Deutsch" -> "Hallo, Welt!" 45: | "English" -> "Hello, World!" 46: | _ -> "Language not supported :(\nYou can add a language via PR to https://gitlab.com/shonfeder/ocaml_webapp" 47: in 48: basic_page Html.[p [txt greeting]]
4.3.4 Forms that Submit Post Requests
Finally, let’s see how we can generate a form that will submit a post request.
The add_excerpt_page
generates a page serving a simple form that collects
information describing an excerpt from a book, article, or other source:
50: let add_excerpt_page = 51: let txt_input name = 52: Html.[ label ~a:[a_label_for name] [txt (String.capitalize name)] 53: ; input ~a:[a_input_type `Text; a_name name] () ] 54: in 55: let excerpt_input = 56: let name = "excerpt" in 57: Html.[ label ~a:[a_label_for name] [txt (String.capitalize name)] 58: ; textarea ~a:[a_name name] (txt "") ] 59: in 60: let submit = 61: Html.[input ~a:[ a_input_type `Submit; a_value "Submit"] () ] 62: in 63: basic_page 64: Html.[ form ~a:[a_method `Post; a_action "/excerpts/add"] 65: (List.map ~f:p 66: [ txt_input "author" 67: ; excerpt_input 68: ; txt_input "source" 69: ; txt_input "page" 70: ; submit 71: ])]
Lines 51 through 62 define self-documenting helpers that we use to build up the
parts of the form. We construct the page with the form using the named attribute
argument a
to configure the form to send a `Post
request to our
/excerpts/add
endpoint.
We serve the add_excerpt_page
in response to GET requests to /excerpts/add
:
29: let get_excerpts_add = get "/excerpts/add" begin fun _req -> 30: respond' @@ 31: Content.add_excerpt_page 32: end
But we respond to POST requests at /excerpts/add
via a different handler.
Examining this handler will lead us into our interactions with the database, so
before discussing how to use the Db
api in a post route, we’ll walk through
the database code.
4.4 Interfacing with the Database
The database interface is entirely encapsulated within the ./lib/db.ml module. Let’s review the API defined in ./lib/db.mli:
1: open Opium.Std 2: 3: (** {{1} Type aliases for clearer documentation and explication} *) 4: 5: type 'err caqti_conn_pool = 6: (Caqti_lwt.connection, [> Caqti_error.connect] as 'err) Caqti_lwt.Pool.t 7: 8: type ('res, 'err) query = 9: Caqti_lwt.connection -> ('res, [< Caqti_error.t ] as 'err) result Lwt.t 10: 11: (** {{1} API for the Opium app database middleware }*) 12: 13: val middleware : App.builder 14: (** [middleware app] equips the [app] with the database pool needed by the 15: functions in [Update] and [Get]. It cannot (and should not) be accessed 16: except through the API in this module. *) 17: 18: module Get : sig 19: (** Execute queries that fetch from the database *) 20: 21: val excerpts_by_author : string -> Request.t -> (Excerpt.t list, string) Lwt_result.t 22: (** [excerpts_by_author author req] is the [Ok excerpts] list of all the 23: [excerpts] by the [author], if it succeeds. *) 24: 25: val authors : Request.t -> (string list, string) Lwt_result.t 26: (** [authors req] is the [Ok authors] list of all the [authors] in the 27: database, if it succeeds. *) 28: 29: end 30: 31: module Update : sig 32: (** Execute queries that update the database *) 33: 34: val add_excerpt : Excerpt.t -> Request.t -> (unit, string) Lwt_result.t 35: (** [add_excerpt excerpt req] is [Ok ()] if the new excerpt can be inserted 36: into the database. *) 37: 38: end 39: 40: (** {{1} API for database migrations } *) 41: 42: module Migration : sig 43: (** Interface for executing database migrations *) 44: 45: type 'a migration_error = 46: [< Caqti_error.t > `Connect_failed `Connect_rejected `Post_connect ] as 'a 47: 48: type 'a migration_operation = 49: unit -> Caqti_lwt.connection -> (unit, 'a migration_error) result Lwt.t 50: 51: type 'a migration_step = string * 'a migration_operation 52: 53: val execute : _ migration_step list -> (unit, string) result Lwt.t 54: (** [execute steps] is [Ok ()] if all the migration tasks in [steps] can be 55: executed or [Error err] where [err] explains the reason for failure. *) 56: 57: end
This API encapsulates all the database operations so that no code in the rest of the app can read from or write to the database.
Now let’s walk through the implementation.
4.4.1 Implementing the Database Connection
To implement the database connection, we only need to construct the connection
URI and feed it to Caqti. We use Caqti_lwt
to create a pool of threads that can
access this connection:
11: (** Configuration of the connection *) 12: let url = "localhost" 13: let port = 5432 14: let database = "ocaml_webapp" 15: let connection_uri = Printf.sprintf "postgresql://%s:%i/%s" url port database 16: 17: (* [connection ()] establishes a live database connection and is a pool of 18: concurrent threads for accessing that connection. *) 19: let connect () = 20: connection_uri 21: |> Uri.of_string 22: |> Caqti_lwt.connect_pool ~max_size:10 23: |> function | Ok pool -> pool 24: | Error err -> failwith (Caqti_error.show err) 25: 26: (* [query_pool query pool] is the [Ok res] of the [res] obtained by executing 27: the database [query], or else the [Error err] reporting the error causing 28: the query to fail. *) 29: let query_pool query pool = 30: Caqti_lwt.Pool.use query pool
- Explanation of the code
The purpose of the
connect
function is largely to seal in the gnarly, polymorphic variant error type used by Caqti, so that the rest of the app doesn’t need to be aware of it. We map the error type to a string representation of it, but a productionized app you’d likely want to create a more robust error handler. In a production app, we’d want more robust error handling than just throwingfailwith
(probably using aresult
type). The same goes for the err handling in thequery_pool
helper:NOTE: The configuration variables hard code our database connection parameters into the source, which is expedient for this tutorial, but not a good practice for a real project, where we’d want to take these out of a configuration file or environment variable.
4.4.2 Implementing the database middleware
We need to make the database connection available at any route, so we can query
and update it as needed. This is achieved by defining a piece of Opium
middleware that initiates the connection pool and adds it to the environment of
our opium
app:
40: (* Seal the key type with a non-exported type, so the pool cannot be retrieved 41: outside of this module *) 42: type 'err db_pool = 'err caqti_conn_pool 43: let key : _ db_pool Opium.Hmap.key = Opium.Hmap.Key.create ("db pool" , fun _ -> sexp_of_string "db_pool") 44: 45: (* Initiate a connection pool and add it to the app environment *) 46: let middleware app = 47: let pool = connect () 48: in 49: let filter handler (req : Request.t) = 50: let env = Opium.Hmap.add key pool (Request.env req) in 51: handler {req with env} 52: in 53: let m = Rock.Middleware.create ~name:"database connection pool" ~filter in 54: middleware m app 55: 56: (* Execute a query on the database connection pool stored in the request 57: environment *) 58: let query_db query req = 59: Request.env req 60: |> Opium.Hmap.get key 61: |> query_pool query
- Explanation of the code
The
key
is used to look up the database connection from the environment (the environment being a heterogeneous valued hashmap implemented using Hmap). Thedb_pool
type alias is kept private, and we do not export the key itself, so there is no way to retrieve the connection from the environment outside of theDb
module.A piece of middleware is of type
Opium.App.builder
, i.e.,Opium.App.t -> Optium.App.t
. All of theDb
middleware
logic is in its inlinefilter
function. In general, such filters take ahandler
and a request,req
, do some stuff to construct a new request, and then handle the new request with the givenhandler
. In this case, all we’re doing is making sure that the database connectionpool
is available in the environment of the request. We useopium
’sRock.Middleware.create
to create our new middleware and apply it to the given app usingOpium.Std.middleware
.The
query_db
function is just a helper used to execute queries on the database connection stored in a request. If we wanted to let the client code execute arbitrary queries on the database connection, we could expose this function in the API. However, we choose to keep it hidden, so that the database can only be accessed through the API we define below in theGet
andUpdate
submodules.
4.4.3 Writing Database Queries using ppx_rapper
This project uses Roddy MacSween’s ppx_rapper for writing the SQL queries. It abstracts away some awkward boilerplate required by Caqti (tho writing queries directly in Caqti is not diffcult. See the Additional Resources for material covering this):
ppx_rapper
generates functions to form queries based on a simple templating
syntax on top of PostgreSQL:
- Input parameters are indicated with
%type{param_name}
, which will produce a named argument for the function,~param_name:type
. When the flagrecord_in
is supplied, these parameters are taken from record fields of the same name. - Outputs are indicated with
@type{expression}
, which, when therecord_out
flag is supplied, will return a record{expression : type; ...}
including fields for all indicated values of the specified types.
To help illustrate how this works, compare the queries below with the functions
in the Get
and Update
submodules which execute the queries:
56: (** Collects all the SQL queries *) 57: module Query = struct 58: type ('res, 'err) query_result = ('res, [> Caqti_error.call_or_retrieve ] as 'err) result Lwt.t 59: 60: let add_excerpt 61: : Excerpt.t -> Caqti_lwt.connection -> (unit, 'err) query_result = 62: let open Excerpt in 63: [%rapper execute 64: {sql| 65: INSERT INTO excerpts(author, excerpt, source, page) 66: VALUES (%string{author}, %string{excerpt}, %string{source}, %string?{page}) 67: |sql} 68: record_in 69: ] 70: 71: let get_excerpts_by_author 72: : author:string -> Caqti_lwt.connection -> (Excerpt.t list, 'err) query_result = 73: let open Excerpt in 74: [%rapper get_many 75: {sql| 76: SELECT @string{author}, @string{excerpt}, @string{source}, @string?{page} 77: FROM excerpts 78: WHERE author = %string{author} 79: |sql} 80: record_out 81: ] 82: 83: let get_authors 84: : unit -> Caqti_lwt.connection -> (string list, 'err) query_result = 85: [%rapper get_many 86: {sql| 87: SELECT DISTINCT @string{author} 88: FROM excerpts 89: |sql} 90: ] 91: end 92: 93: (* Execute queries for fetching data *) 94: module Get = struct 95: let excerpts_by_author author : Request.t -> (Excerpt.t list, string) Lwt_result.t = 96: query_db (Query.get_excerpts_by_author ~author) 97: 98: let authors : Request.t -> (string list, string) Lwt_result.t = 99: query_db (fun c -> Query.get_authors () c) 100: end 101: 102: (* Execute queries for updating data *) 103: module Update = struct 104: let add_excerpt excerpt : Request.t -> (unit, string) Lwt_result.t = 105: query_db (fun c -> Query.add_excerpt excerpt c) 106: end
- Explanation of the code
Query.add_excerpt
willexecute
the query defined in thesql
string. The function generated byppx_rapper
has no output values, as indicated by the absence of any leading@
, so it will return aunit
. The SQL template takes four input values,%string{author}
,%string{excerpt}
,%string{source}
, andstring?{page}
.%string?{page}
indicates thatpage
will be of typestring option
. The use of therecord_in
dictates that these values will be taken from a record with the corresponding field names and types; i.e., a record of typeExcerpt.t
:1: type t = 2: { author: string 3: ; excerpt: string 4: ; source: string 5: ; page: string option 6: }
The resulting type of
Query.add_excerpt
expresses that the function takes a connection to the database and inserts the excerpt into it, returning anOk ()
if all goes well.In the query function
get_excerpts_by_author
we see a query which willget_many
results. Each result will be arecord_out
, with fields and types as dictated by the terms prefixed with a@
, i.e., theExcerpt.t
record. We also see that the query takes one input parameter, the%string{author}
. Since we do not specify that we’re expecting arecord_in
, the resulting function will take a named parameter~author:string
.These query functions are used in the
Get
andUpdate
submodules exposed in the external API. Each function in the external API ends up taking aRequest.t
from the OpiumApp.t
and returning any fetched values as anLwt.t (Ok res)
(or an error).
4.4.4 Using the Db
API in a Route
The handler for requests to post "/excerpts/add"
illustrates usage of the API:
34: let respond_or_err resp = function 35: | Ok v -> respond' @@ resp v 36: | Error err -> respond' @@ Content.error_page err 37: 38: let excerpt_of_form_data data = 39: let find data key = 40: let open Core in 41: (* NOTE Should handle error in case of missing fields *) 42: List.Assoc.find_exn ~equal:String.equal data key |> String.concat 43: in 44: let author = find data "author" 45: and excerpt = find data "excerpt" 46: and source = find data "source" 47: and page = match find data "page" with "" -> None | p -> Some p 48: in 49: Lwt.return Excerpt.{author; excerpt; source; page} 50: 51: let post_excerpts_add = post "/excerpts/add" begin fun req -> 52: let open Lwt in 53: (* NOTE Should handle possible error arising from invalid data *) 54: App.urlencoded_pairs_of_body req >>= 55: excerpt_of_form_data >>= fun excerpt -> 56: Db.Update.add_excerpt excerpt req >>= 57: respond_or_err (fun () -> Content.excerpt_added_page excerpt) 58: end
- Explanation of the code
The
respond_or_err
helper deals with possibleError err
values of aresult
, passingOk
values back as responses if possible, or else responding with an error page.The
excerpt_of_form_data
function takes an association list of key-value pairs representing form data and constructs anExcerpt.t
record.Finally, the
post_excerpts_add
handler decodes a response body containing form data (encoded according to theapplication/x-www-form-urlencoded
content type) into an association list, uses the previous helper to build anExcerpt.t
, and then gives theExcerpt.t
to theDb.Update.add_excerpt
function, which inserts theExcerpt.t
into the database via the connection stored in the environment of thereq
.
4.4.5 Implementation of a Rudimentary Database Migration Utility
This app includes naive, bespoke migration and rollback utilities. They are
defined in the migration
directory, and make use of the Db.Migration
module:
108: module Migration = struct 109: type 'a migration_error = 110: [< Caqti_error.t > `Connect_failed `Connect_rejected `Post_connect ] as 'a 111: 112: type 'a migration_operation = 113: unit -> Caqti_lwt.connection -> (unit, 'a migration_error) result Lwt.t 114: 115: type 'a migration_step = string * 'a migration_operation 116: 117: let execute migrations = 118: let open Lwt in 119: let rec run migrations pool = match migrations with 120: | [] -> Lwt_result.return () 121: | (name, migration) :: migrations -> 122: Lwt_io.printf "Running: %s\n" name >>= fun () -> 123: query_pool (fun c -> migration () c) pool >>= function 124: | Ok () -> run migrations pool 125: | Error err -> return (Error err) 126: in 127: return (connect ()) >>= run migrations 128: end
A migration is just a pair of a string
(the name of the migration step) and a
query
. The execute
function takes a list of migrations
, initiates a
connection
, and then runs each query on the pool, so long as they result in
Ok
values.
See ./migrate/migrate.ml and ./migrate/rollback.ml for examples of this simplistic scheme. This code has no awareness of the current version of the database, so it will dumbly try to run all migrations: something to improve in a productionized app.
5 Conclusion
This tutorial has taken us from initial installation of an OCaml development environment, through configuration and setup of a project, to development of a toy webapp, complete with an interface to PostgreSQL. There is still much more to cover, and many more OCaml libraries to explore and demo. But hopefully this material provides enough footholds to help a novice Caml rider get going.
6 Corrections, Suggestions, and Feedback
If you have any questions or feedback, please
- Fork this repo and make a PR to correct or improve something
- Open an issue
- Contact me via my homepage
Thanks in advance for any contributions!
7 Additional Resources
7.1 Documentation
7.2 Tutorials
- Bobby Priambodo’s tutorial Interfacing OCaml and PostgreSQL with Caqti
7.3 Examples
- The Opium examples
- Bobby Priambodo’s Opium and PostgreSQL example app
7.4 About this tutorial
List of Listings
- Listing 1: The project configuration in ./dune-project
- Listing 2: Helper rules in ./Makefile
- Listing 3: Databse port configuration in ./lib/db.ml
- Listing 4: The executable defined in ./bin/main.ml
- Listing 5: Declaration of library dependencies from the
logs
package in ./bin/dune - Listing 6: The function to add our routes to the app in ./lib/route.ml/
- Listing 7: Simple handlers in ./lib/route.ml
- Listing 8: The
<head>
element defined in ./lib/content.ml - Listing 9: Generating HTML for use by
opium
in ./lib/content.ml - Listing 10: The
hello
handler taking a path param ./lib/route.ml - Listing 11: The
hello_page
function taking a langauge parameter in ./lib/content.ml - Listing 12: The
add_excerpt_page
function generating a form in ./lib/content.ml - Listing 13: The
get_excerpts_add
handler serving our form in ./lib/route.ml - Listing 14: The API for the
Db
module in ./lib/db.mli - Listing 15: Implementation of the database connection in ./lib/db.ml
- Listing 16: Implementation of the database middleware in ./lib/db.ml
- Listing 17: Defining database queries in ./lib/db.ml
- Listing 18: The type for
Excerpt.t
records, defined in ./lib/excerpt.ml - Listing 19: Using the
Db
API to handle a post request with form data in ./lib/route.ml - Listing 20: The database migration API defined in ./lib/db.ml
Footnotes:
I plan to add an optional sqlite backend, which should make it easier to set up an run.
Project configuration in OCaml is currently more involved than anyone would like. We’re making steady and rapid progress on improving the situation, and this section should be updated to keep pace. If you read this tutorial and find the setup or configuration out of date, please open a PR or file an issue.
This probably isn’t the ideal way to handle the logging configuration, but it is straightforward and suffices for the context of this tutorial. See https://gitlab.com/shonfeder/ocaml_webapp/merge_requests/1 for a discussion of some alternatives and the benefits and drawbacks of this approach.