Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/jbeeko/cfworker-web-api

CloudFlare workers in FSharp
https://github.com/jbeeko/cfworker-web-api

Last synced: 3 months ago
JSON representation

CloudFlare workers in FSharp

Awesome Lists containing this project

README

        

# Cloudflare Workers in FSharp - II
This is the second of several posts exploring FSharp servicing HTTP requests using the Cloudflare Workers infrastructure. Building on ["Hello World"](https://github.com/jbeeko/cfworker-hello-world), this post will show how to create a web API with routing, explore the Worker KVStore and provide tooling for dead simple **edit -> save -> deploy** workflow. It then puts that all together to create a Contact REST API. Finally a bit of benchmarking shows performance is still excellent.

> **NOTE:** Updated August 2020 to use the Cloudflare Workers CLI `wrangler` and deploy to the `workers.dev` route by default. It has also been updated to demonstrate how to use the Alpha release of the `wrangler dev` command. This command supports a local server, a file watcher and printing console messages to the local terminal.

> **NOTE:** Cloudflare now supports a free tier for Workers. However the free tier will not provide access to the CRUD operations for `/contacts`. Trying to to access the KV Store with a free plan will result in an error. The rest of this demo should work.

[**Skip the recap and description, take me to the hands on part.**](#tooling-and-workflow)

## Worker Recap
As described in the first post, Cloudflare workers are "functions as a service". But rather than starting a VM or a container to run functions, workers are run in [Chrome V8 isolates](https://v8.dev/docs/embed) via the [`ServiceWorker`](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) API. The are written in JavaScript, any language that compiles to JavaScript or any language that compiles to [WASM](https://webassembly.org). [This post](https://blog.cloudflare.com/cloud-computing-without-containers/) is a good introduction to workers.

## Refactoring the Worker
In the first post the entire F# worker was in one file Worker.js at the project root:
```
// 1. Module and import declarations
module Worker
open Fable.Core
open Fable.Import.Browser
open Fable.Import.JS

// 2. JS interop statements
[]
let addEventListener (e:FetchEvent->Promise) : unit = jsNative

[]
let newResponse (a:string) (b:string) : Response = jsNative

// 3. The worker code
// Define a request handler which creates an an appropreate Response
// and returns a Promise
let private handleRequest (req:Request) =
promise {
let txt = sprintf "Hello from the F# World at: %A" System.DateTime.Now
return newResponse txt "200"}

// Register a listner for the ServiceWorker 'fetch' event. That listner
// will extract the request and dispath it to the request handler.
addEventListener (fun (e:FetchEvent) ->
e.respondWith (U2.Case1 (handleRequest e.request)))
```

That is a bit messy. Lets split the worker into several files:
- `src/Worker.fs`- the routing logic and entry point
- `src/Stats.fs` and `src/Contacts.fs` - Files for specific handlers or sub-apps. For example `Contacts.fs` manages a Contact API.
- `src/WorkerInterop.fs`- interop / utility functions

### Worker.fs
Like before this adds a listener for fetch events and then dispatches the request The key line is:
`App.routeRequest (verb req) (path req) req`

This calls `routeRequest` with three parameters, the HTTP verb, the path and the request itself.

```
module Worker
open Fable.Core
open Fable.Import.Browser
open WorkersInterop
open System

// Request router written as a match statement on the
// HTTP Verb and the route. It handles multi-part routes,
// route variables, and subroutes.
let rec private routeRequest verb path req =
match (verb, path) with
| GET, [] -> textResponse "Home sweet home!"
| GET, ["hello"] -> textResponse (sprintf "Hello world from F# at: %A" DateTime.Now)
| GET, ["bye"] -> textResponse "Goodbye cruel world."
| GET, ["hello"; "bye"] -> textResponse "I say hello and the goodbye."
| GET, ["hello"; "bye"; i] -> textResponse (sprintf "Hello-bye id: %s" i)
| GET, ["stats"] -> Stats.stats req
| _, "contacts"::subPath -> Contacts.routeRequest verb subPath req
// Other routes
| _,_ -> noHandler req
and private noHandler req =
newResponse (sprintf "No handler for: %s on: %s" req.method req.url ) "404" |> wrap

// Dispatch to the web App wich will generate a
// Response and return it as a Promise
let private handleRequest (req:CFWRequest) =
routeRequest (verb req) (path req) req

// Register a listner for the ServiceWorker 'fetch' event
addEventListener (fun (e:FetchEvent) ->
e.respondWith (U2.Case1 (handleRequest (e.request :?> CFWRequest))))

```
#### A few comments on routing
The router is implemented as a pattern match statement on the HTTP verb and the path. This approach is lifted from the ReasonML router ["pattern match your way to glory"](https://reasonml.github.io/reason-react/docs/en/router.html#match-a-route). One thing that stands out is how simple and approachable this code is. Given a HTTP verb and a path represented as a collection of strings we pattern match. But despite this simplicity it is quite powerful.

**Paths can be any length**- and any segment of it may be "wild carded" using `_`.

**Path parameters**- Pattern matching makes it easy to extract one or more path parameters. For example:

`| GET, ["hello"; "bye"; i] -> helloByeId req i`

extracts the last element of the path as an id passing it as a parameter to `helloByeId`. It would be just easy to extract an internal path element or several elements.

**Sub-apps are trivial**- Creating and routing to stand-along sub-applications is trivial. For example:

`| _, "contacts"::subPath -> Contacts.routeRequest verb subPath req`

uses pattern matching on a list to determine the sub-app Contacts should be routed to. Contacts implements the same `routeRequest` signature and is invoked with the tail of the path. By using something like `"contacts"::"address"::subPath` you could match deeper than the first sub-path.

**Each handler returns a promise**- So why does each handler not return a Response and let the Worker put it in a promise just before returning to the event? The reason for this is that the `body` member of request is a promise. This can only be extracted from in a promise via `let!` but the return from this will be a promise. So any handler that needs access to the body will be returning a promise, so to keep it consistent they all do.

**Why not use combinators for routing?**- [Suave.io](https://suave.io/) and [Giraffe.io](https://github.com/giraffe-fsharp/Giraffe), two recent functional first web frameworks both use combinators and functional composition to build routers. Here is a router from the Giraffe documentation:
```
choose [
GET >>=
choose [
route "/" >>= setBodyAsString "Index"
route "/ping" >>= setBodyAsString "pong"
]
POST >>=
choose [
route "/submit" >>= setBodyAsString "Submitted!"
route "/upload" >>= setBodyAsString "Uploaded!"
]
]
```
In its shape this looks a bit similar the match statement, sort of like a set of nested match statements with `choose` taking the place of `match ... with`. But the implementation is completely different. `GET`, `route` and `setBodyAsString` are HttpHandler functions which take a `HTTPContext` parameter (wrapping the request and response) and return a `HttpContext option`. These functions are composed into chains of one or more handlers using `>>=`. As long as the return value remains `Some context` the combinator `>>=` applies the next function in the composition chain. As soon as `None` is returned the remaining functions in the chain are skipped and `None` is returned.

`choose` is a combinator that takes a list of handler chains and returns either the result of the first chain that evaluates to `Some context` or `None` if there is no such chain.

The power of combinators is that each handler returns a new `HttpContext` and can rewrite both its request and response records for use by the next handler. For example some handlers might do nothing but add headers to requests or post processing responses into PDF (e.g. `route "/ping" >>= setBodyAsString "pong" >>= toPdf`). For a better explanation see [this](https://dusted.codes/functional-aspnet-core). The disadvantage is that it requires a combinator library and can appear a bit "magic" to newcomers. The match statement is very obvious, suscinct and took only minutes to write.

**Match on Tuples or Records?** The match statement is given the tuple `(verb, path)` to match on. If the API needed to match on an `AccessLevel` in only one case, all cases of the match statement would need to be re-written.

But if rather than matching on a tuple the statement matched on a record then each case could match on any combination of the records fields in ay order. Missing fields are considered to be like a `_` in the tuple case. Here is what this would look like:
```
// Define a route spec
type RouteSpec = {
mth: string
pth: string list
}

// and a router that routes based on a spec
match req with
| {mth="GET"} -> req2.pth
| {pth = ("contacts"::subpath)} -> subpath
| _ -> ["fail"]
```
Then if `req` is `{mth = "GET"; pth = ["contacts"; "44"]}` the router will return `["contacts"; "44"]` but if it is `{mth = "POST"; pth = ["contacts"; "44"]}` then the router will return `["44"]`.

We could now redefine `RouteSpec` as:
```
type RouteSpec = {
mth: string
pth: string list
auth: AccessLevel
}
```
and route by matching on `auth` where needed without breaking any code. The disadvantage is that the router does not look as clean and simple.

## Contact CRUD Operations
> NOTE: Workers KV requires a paid plan. Trying to to access the KV Store with a free plan will result in an error. This demo will deploy to a free plan but will fail on the route `/contacts`'

The Cloudflare Workers paid plan supports a data store called [Workers KV](https://developers.cloudflare.com/workers/kv/). This is a globally distributed highly read-biased, eventually consistent key-value store. Key parameters are:
* 100,000 key reads per second
* 1 write per second
* global write and delete propagation within 10 seconds
* 512 byte key size
* 2 meg value size
* billions of keys/namespace
* 20 namespaces/account
* keys can be set to automatically expire and garbage collect

### Configuring Workers KV
There is a [REST API](https://developers.cloudflare.com/workers/kv/api/) for managing all things KV, but initially it is simplest to login-to Cloudflare and use the portal. The first step is to add a `Namespace` to your account. This effectively creates a key-value store with the given name. In this case I've created the namespace `Rec Room`.

![](resouces/add-namespace.png)

To access the store from a workers the `Namespace` needs to be bound to a value accessible within the Worker code. This can be done on the Workers editor. Here I am binding `Rec Room` to `KVBinding`.

![](resouces/add-binding.png)

Once the names space has been bound like this there will be a three member API available to the Worker Javascript:
* `KVBinding.put(key, value)`
* `KVBinding.get(key)`
* `KVBinding.delete(key)`

The `WorkersInterop.fs` file exposes this API using the following `Emit` statements.
```
[]
let kvGet(key:string) : Promise = jsNative

[]
let kvPut(key:string) (value:string) : Promise = jsNative

[]
let kvDelete(key:string) : Promise = jsNative
```
This works find, but note that the namespace binding is hard-coded to `KVBinding` so you will need to use the same or change this file.

> NOTE: In addition to those basics the API supports a few options such as key expiration and typed values.

### Using the Workers KV
Usage to of the API could not be simpler. `Contacts.fs` implements a tiny web api that supports 'POST, GET, PUT and DELETE` of a contact record.

`GET` is as simple as:
```
getContact req i =
promise {
let! getRes = kvGet i
match getRes with
| None -> return newResponse (sprintf "No contact with id %s" i) "404"
| Some json -> return newResponse json "200"
}
```

`POST` is almost as simple:
```
postContact req =
promise {
let! body = (req.text())
let contact = Decode.fromString contactDecoder body
match contact with
| Ok c ->
do! kvPut c.id body
return newResponse body "200"
| Error e -> return newResponse (sprintf "Unable to process: %s because: %O" body e) "200"
}
```

## Deploying To Cloudfare from a CLI
Part I [demonstrated](https://github.com/jbeeko/cfworker-hello-world/#deploying-manually) deploying a worker by pasting the JavaScript into an online editor. That works but better is deploying as part of a build process. Cloudflare offers several [options](https://developers.cloudflare.com/workers/deploying-workers/):
* a Cloudflare HTTP API
* the serverless framework
* Terraform
* The Cloudflare CLI `wrangler`

Cloudflare is investing ever more in Wrangler so that is the obvious way forward.

### Getting an Account and Configuration

**Getting a Cloudflare Account**
If you don't already have one you can sign up for CloudFlare for free; `cloudflare.com`. Free accounts will not let you have a custom domain. Instead once you have signed up go to the workers section and register for a `workers.dev` domain so you can deploy workers there.

**Install and Configure Wrangler**
This repo uses the Cloudflare CLI called Wranger for build and deployment. To install wrangler [see](https://www.npmjs.com/package/@cloudflare/wrangler/v/1.4.0-rc.5). The prefered way to configure wrangle is via environment variables, e.g.
```
# e.g.
CF_ACCOUNT_ID=youraccountid
CF_API_TOKEN=superlongapitoken
```

If you have an account, free or paid, you can obtain a `CF_API_TOKEN` [like this.](https://support.cloudflare.com/hc/en-us/articles/200167836-Managing-API-Tokens-and-Keys#12345681)

### Three Ways to Deploy
**Publish to preview** - This the simplest approach. The command `wranger dev` will will build the project and publish it to a _Preview Environment_ on the Cloudflare network, start a local server on `localhost:8787` and, create a connection bettween the terminal and the deployed worker ensuring exceptions and console logs are dumped into the terminal. If you have created an account and configured environment variables as above this should 'just work'.

**Publish to workers.dev** - In this approach the command `wrangler publish` deploys the worker to the subdomain of `workers.dev` you created when you created your account. The worker will be live on the URL `https://..workers.dev`. The `wrangler.toml` file in this demo supports this as it is.

**Publish to your domain** - This assumes you have a paid account with a custom domain registered. To publish to a route on your custom domain you need to configure the `wrangler.toml` file as described [here](https://developers.cloudflare.com/workers/tooling/wrangler/configuration/). This is the only way to exercise the CRUD operations on the route `/contact`.

### Debugging Workers
Debugging support for Workers is rudimentary. The best appoach is to debug by deploying to to a preview environment with `wrangler dev`. This way you will see console messages in terminal. Various tips and tricks are discussed [here.](https://developers.cloudflare.com/workers/writing-workers/debugging-tips/)

## Protecting your API
There are three approaches to protecting your API from abuse.:

**Add Rate Limiting:** You can enable rate limiting for your domain. In this case you will not be charged for the first 10,000 _unblocked_ requests. After that you will be charged %0.05/10,000 successful requests, or $5.00/1,000,000. Make sure you set the rate low enough or you may pay more this way than by leaving the API open.

**Use Cloudflare Access:** You can sign up for Cloudflare Access. This will let you protect your API with a [Service Token](https://developers.cloudflare.com/access/connecting-to-apps/service-token/). Cloudflare Access is a paid service but 5 users with GitHub as the Auth provider seems to be free.

**Don't Worry About It:** Worker invocations cost $5.00/10million. You may decide if someone performs 100million requests you will just pay the $50.00. Enterprise users probably pay even less.

## Scaling and Performance
The simple "Hello World" worker described in part one had excellent performance. Out of the box deployed to a paid account it was able to serve about 5000 requests/sec with an average response time of 17ms.

Querying the more realistic `/contacts` api with the command `ab -k -n 1000000 -c 100 https://rec-room.io/contacts/12` resulted in 1 million requests with a concurrency of 100. These were completed in 385 seconds at a rate of about 2594/requests per second. The mean response time was 38ms. Still very good.
```
$ ab -k -n 1000000 -c 100 https://rec-room.io/contacts/12
This is ApacheBench, Version 2.3 <$Revision: 1826891 $>
...
Server Software: cloudflare
Server Hostname: rec-room.io
Server Port: 443
SSL/TLS Protocol: TLSv1.2,ECDHE-ECDSA-CHACHA20-POLY1305,256,256
TLS Server Name: rec-room.io

Document Path: /contacts/12
Document Length: 104 bytes

Concurrency Level: 100
Time taken for tests: 385.396 seconds
Complete requests: 1000000
Failed requests: 0
Keep-Alive requests: 999049
Total transferred: 536995245 bytes
HTML transferred: 104000000 bytes
Requests per second: 2594.73 [#/sec] (mean)
Time per request: 38.540 [ms] (mean)
Time per request: 0.385 [ms] (mean, across all concurrent requests)
Transfer rate: 1360.70 [Kbytes/sec] received

Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 3.9 0 1259
Processing: 17 38 34.8 30 7702
Waiting: 16 38 34.8 30 7702
Total: 17 38 35.1 30 7702

Percentage of the requests served within a certain time (ms)
50% 30
66% 35
75% 39
80% 42
90% 55
95% 77
98% 136
99% 185
100% 7702 (longest request)
```

>NOTE: Rate limiting is enabled across `rec-room.io`. If you wish to explore performance you will need to sign up for an account and deploy to it.

## Tooling and Workflow
The repository [https://github.com/jbeeko/cfworker-web-api](https://github.com/jbeeko/cfworker-web-api) will let you replicate the Worker change it and build a modified version.

### Prerequisites
This repository was developed under OSX. But you should be able to use Windows, OSX or Linux. The following tooling is assume to be installed before you clone the repository:
* **dotnetcore 2.1** or greater - [see](https://dotnet.microsoft.com/download/dotnet-core/2.1)
* **FSharp and an Editor** - [see](https://docs.microsoft.com/en-us/dotnet/fsharp/get-started/get-started-vscode).
* **Node and Node Package Manager** - [see](https://nodejs.org/en/download/) or via Homebrew
* **Yarn** - [see](https://www.npmjs.com/package/yarn) or via Homebrew
* **Wrangler** - [see](https://www.npmjs.com/package/@cloudflare/wrangler/v/1.4.0-rc.5).
* **REST Client** - [see](https://marketplace.visualstudio.com/items?itemName=humao.rest-client). With this installed you can open `testing.rest` and execute the test HTTP requests in the file. This is very nice to have for those using VSCode.

### Building and Running

**Get the code**- clode the repository with: `git clone https://github.com/jbeeko/cfworker-web-api`

**Install**- install dependancies and node modules with: `yarn install`

**Configure Wrangler** - The prefered way to configure wrangle is via environment variables, e.g.
```
# e.g.
CF_ACCOUNT_ID=youraccountid
CF_API_TOKEN=superlongapitoken
```

How to obtain a `CF_API_TOKEN` is described [here](https://support.cloudflare.com/hc/en-us/articles/200167836-Managing-API-Tokens-and-Keys#12345681)

**Preview** - start a local server that will handler your requests on `localhost:8787` with `wrangler dev`. This will also start a file watcher and re-load as needed. If successfull the terminal will show:
```
MBPro:cfworker-web-api $ wrangler dev
`wrangler dev` is currently unstable and there are likely to be breaking changes!
For this reason, we cannot yet recommend using `wrangler dev` for integration testing.
Please submit any feedback here: https://github.com/cloudflare/wrangler/issues/1047
fable-compiler 2.4.16
fable: Compiled src/Worker.fsproj
...
fable: Compiled .fable/Thoth.Json.3.5.0/Decode.fs
fable: Compiled .fable/Thoth.Json.3.5.0/Types.fs
🌀 Detected changes...
✨ Built successfully, built project size is 24 KiB.
```

**Deploy** - build and deploy to Cloudflare with: `wrangler publish`. Assuming you have a Worker enabled Cloudflare account and your credentials are correct this will connect to Cloudflare and upload `worker.js` as the worker to run based on the `worker.toml` file. By default this is `cfworker..workers.dev`. Where `subdomain` is your Cloudflare workers dev subdomain. If successful the terminal will show the following:
```
...
MBPro:cfworker-web-api $ wrangler publish
fable-compiler 2.4.16
fable: Compiled src/Worker.fsproj
...
fable: Compiled .fable/Thoth.Json.3.5.0/Types.fs
✨ Built successfully, built project size is 24 KiB.
✨ Successfully published your script to https://cfworker..workers.dev
MBPro:cfworker-web-api $
...
```
The worker is now live on `https://cfworker..workers.dev` file.

**Testing**- If the VSCode REST Client extension is installed you can test the API by opening testing.rest. That file contains a series of http requests. They can be executed by clicking on the 'Send Request' hover link. If VSCode is not available use cURL PostMan or any other API test tool.

![](resouces/contact-post.png)

## Summary
This installment started by refactoring the worker from Part I into multiple files. It then added a router based on FSharp match statements. Several different routing patterns were added as examples to show the flexibility of the approach. A Contact sub-app was created that takes a JSON post body to perform CURD operations on Contacts. Finally we added some support tooling for automated deployment of workers includeing running a local server with a file watch facility.

## Resources
[See the first post.](https://github.com/jbeeko/cfworker-hello-world/#resources)

[Worker KV](https://developers.cloudflare.com/workers/kv/)

[Getting a CF Token](https://support.cloudflare.com/hc/en-us/articles/200167836-Managing-API-Tokens-and-Keys#12345681)

[Debugging Tips](https://developers.cloudflare.com/workers/writing-workers/debugging-tips/)

[Wrangler](https://developers.cloudflare.com/workers/tooling/wrangler)