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.