Write and deploy an HTTP service

In this walk-through we'll create a simple, stateless HTTP service and deploy it on the Unison Cloud. We'll be using Unison’s library for authoring HTTP services: the Routes library. Here's what we'll cover:

  • Defining service endpoints
  • Using Route helper functions for returning responses
  • Turning your Routes based service into something the Cloud can deploy
  • Deploying your HTTP service

Follow along with our video tutorial

Project set up

First create a project to house your service and install the Routes and Cloud client libraries.

.> project.create time-service
time-service/main> lib.install @unison/routes
time-service/main> lib.install @unison/cloud

The Routes library is our high-level HTTP service library and the Cloud client library is Unison's library for interacting with the Cloud platform.

Create your first service route

Let's write a service route that responds to "/time" with the current time in UTC, encoded in a JSON blob. A handy pattern to use is create one service function per path. These service prefixed functions extract query parameters, decode and encode JSON bodies, and translate the success or failure cases of the service into their appropriate HTTP responses. In larger production systems, they could delegate to other functions for business logic.

service.getTime : '{Route, Remote} ()
service.getTime = do
  Route.noCapture GET (s "time")
  instant = Remote.time.now!
  timeText = Instant.atUTC instant |> toBasicISO8601
  ok.json (
    Json.object [
      ("time", Json.text timeText),
      ("timeZone", Json.text "UTC")
    ]
  )

The first line of this function defines an endpoint that has no variable path segments. Because we don't need to capture any information from the URL, we use the Route.noCapture function from the Routes library. It's followed by the HTTP method and the desired path.

The lines that follow the path specification describe what the service should do if the "/time" path is called. It grabs the current time with the call to Remote.time.now! and turns it into text value. Functions like ok.json are provided by the Routes library to remove the boilerplate of composing your HTTP response by hand. They handle things like setting the content type and response body length for you.

Writing a route with path parameters and error handling

Let's write a route that expects a timezone as a path parameter. This one should respond to requests like "time/UTC" with the current time in that time zone.

When you're defining a Route you're actually writing a mini parser. The expression (s "time" / Parser.text) from below is a URI parser that can handle parsing basic types like Text or Nat values from the path. If the path is successfully parsed, the captured segments can be bound to variables (in our case zone = ... , but a tuple could be returned for more than one path segment.)

service.getTimeZone : '{Route, Remote} ()
service.getTimeZone = do
  zone = Route.route GET (s "time" / Parser.text)
  instant = Remote.time.now!
  match (getOffset zone) with
    Some offset ->
      offsetTime = Instant.atOffset instant (offset)
      timeText = toBasicISO8601 offsetTime
      ok.json (Json.object
        [("time", Json.text timeText ), ("timeZone", Json.text zone)]
      )
    Optional.None -> badRequest.text ("Invalid time zone " ++ zone)

getOffset : Text -> Optional UTCOffset
getOffset zone = match zone with
  "ADT" -> Some ADT
  "AKDT" -> Some AKDT
  -- [...]
  _ -> None

This service route also contains some basic error handling. If the caller's timezone code is not found, the badRequest.text function will return a 404 error to the user.

Pro-tip: Avoid pushing the HTTP error response code logic down into your business logic functions! We could have had our timezone pattern match function actually return the 404 error, like this:

-- 🙅🏻‍♀️ Don't do this
getOffset : Text -> {Route} Optional UTCOffset
getOffset zone = match zone with
  -- [...]
  _ ->
    badRequest.text ("Invalid time zone " ++ zone)
    None

But that makes it more difficult to chase down all the places where HTTP responses might be being generated.

Combining routes

We've written two routes. Let's bind them together into a proper service.

timeService : HttpRequest ->{Exception, Remote} HttpResponse
timeService =
  use Route <|>
  Route.run ( service.getTimeZone <|> service.getTime )

Think of <|> as the logical "or" operator for routing. We give our service routes to the Route.run function, which runs our service when it sees an HttpRequest from a caller and returns the HttpResponse from the service. This is what we’ll deploy to the Cloud next.

Deploying your service on the cloud

You made it! Deploying an HTTP service to the Cloud is the easy part! To test locally, use [Cloud.main.local.serve][localInteractive] and call the deployHttp function with your service as its argument. deployHttp promotes a function from HttpRequest to HttpResponse to the cloud and returns a ServiceHash to identify it. We give our service hash a nice human-readable name in the subsequent lines.

deploy : '{IO, Exception} ()
deploy =
  Cloud.main.local.serve do
    serviceHash = deployHttp !Environment.default timeService
    name = ServiceName.named "time-service"
    ServiceName.assign name serviceHash

Entering run deploy in the UCM console will start the service locally, and you can test it with CURL or the browser on localhost. When you're satisfied, replace Cloud.main.local.serve with Cloud.main and rerun the function to ship your service to the world.