Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Build and deploy a CRUD service

This tutorial writes a small to-do service: a table, five HTTP endpoints, and the least-privilege IAM policy for each one, derived from the code. Then it runs the service on your machine with an emulated Lambda runtime and DynamoDB table. Nothing here calls AWS.

The service

Save this as todos.ssp:

type TodoId = new Str

type Todo = {id: TodoId, title: Str where _.len > 0, done: Bool}

type Change = {title: Str where _.len > 0, done: Bool}

type TodoErr = NotFound{id: Str} | Exists{id: Str}

store Todos = table[TodoId, Todo]

fn create(t: Todo) -> Todo ! db.read[Todos], db.write[Todos], fail[TodoErr]
= do
  if db.get(Todos, t.id).is_some then raise Exists{id: t.id.raw}
  db.put(Todos, t.id, t)
  t

fn read(id: Str) -> Opt[Todo] ! db.read[Todos]
= db.get(Todos, TodoId(id))

fn update(id: Str, c: Change) -> Todo ! db.read[Todos], db.write[Todos], fail[TodoErr]
= do
  old = db.get(Todos, TodoId(id)).ok_or(NotFound{id})
  t = old with title := c.title, done := c.done
  db.put(Todos, TodoId(id), t)
  t

fn remove(id: Str) -> Unit ! db.write[Todos], fail[TodoErr]
= if not db.del(Todos, TodoId(id)) then raise NotFound{id}

fn list() -> List[Todo] ! db.read[Todos]
= db.scan(Todos).sort_by(_.id.raw)

fn open_count(ts: List[Todo]) -> Int
= ts.filter(t => not t.done).len

svc todos
  ep post "/todos" = create
  ep get "/todos/{id}" = read
  ep put "/todos/{id}" = update
  ep delete "/todos/{id}" = remove
  ep get "/todos" = list

test counts_open = open_count([Todo{id: TodoId("a"), title: "a", done: true}, Todo{id: TodoId("b"), title: "b", done: false}]) == 1
  • store Todos = table[TodoId, Todo] declares a table. db.get, db.put, db.del and db.scan operate on it, and they are effects: reading needs db.read[Todos] and writing db.write[Todos].
  • svc todos maps routes to functions. A {id} path segment becomes the parameter named id, the JSON body becomes the one other parameter, and the result becomes the response.
  • TodoId = new Str is a distinct type, so a plain string can’t be used as an id by accident. title: Str where _.len > 0 is checked whenever a Todo or a Change is built, including from a request body.
  • open_count is ordinary pure code with an ordinary test.

The deploy plan

Because every effect is in a signature, the deployer knows exactly what each endpoint can touch:

$ sspur test todos.ssp
1 passed, 0 failed
$ sspur deploy plan todos.ssp --out deploy
svc todos #feee957149d4ad94 -> deploy
  POST /todos              create     dynamodb:GetItem[Todos] dynamodb:PutItem[Todos]
  GET /todos/{id}          read       dynamodb:GetItem[Todos]
  PUT /todos/{id}          update     dynamodb:GetItem[Todos] dynamodb:PutItem[Todos]
  DELETE /todos/{id}       remove     dynamodb:DeleteItem[Todos]
  GET /todos               list       dynamodb:Scan[Todos]
...
  nothing was deployed; build.sh then deploy.sh ship it with your AWS credentials

That is one IAM policy per endpoint, with only the DynamoDB actions that handler can reach, on its own table. deploy plan writes a CloudFormation template, the policies, a native Lambda bootstrap.c, and build.sh and deploy.sh to ship it with your credentials. The template also gives each handler a live alias, alarms and a CodeDeploy group, so a deploy shifts traffic gradually and rolls back on an alarm.

Run it locally

sspur deploy local serves the service on 127.0.0.1 with an emulated Lambda runtime and DynamoDB table that enforce the same policies:

$ sspur deploy local todos.ssp --port 8080
listening on http://127.0.0.1:8080
  #feee957149d4ad94 live; control: sspur deploy swap|promote|rollback|backfill|status --port 8080
  POST /todos -> create
  GET /todos/{id} -> read
  PUT /todos/{id} -> update
  DELETE /todos/{id} -> remove
  GET /todos -> list

In another terminal:

$ curl -s -w ' %{http_code}\n' -X POST localhost:8080/todos -d '{"id":"milk","title":"buy milk","done":false}'
{"id":"milk","title":"buy milk","done":false} 201
$ curl -s -w ' %{http_code}\n' localhost:8080/todos/milk
{"id":"milk","title":"buy milk","done":false} 200
$ curl -s -w ' %{http_code}\n' -X PUT localhost:8080/todos/milk -d '{"title":"buy oat milk","done":true}'
{"id":"milk","title":"buy oat milk","done":true} 200
$ curl -s -w ' %{http_code}\n' -X POST localhost:8080/todos -d '{"id":"milk","title":"again","done":false}'
{"error":{"tag":"Exists","id":"milk"}} 409
$ curl -s -w ' %{http_code}\n' -X POST localhost:8080/todos -d '{"id":"tea","title":"","done":false}'
{"error":"invalid request","detail":"contract violated: field 'title' of Todo where _.len > 0"} 400
$ curl -s -w ' %{http_code}\n' localhost:8080/todos
[{"id":"milk","title":"buy oat milk","done":true}] 200
$ curl -s -w ' %{http_code}\n' -X DELETE localhost:8080/todos/milk
 204
$ curl -s -w ' %{http_code}\n' localhost:8080/todos/milk
{"error":"not found"} 404

How results become responses:

ResultStatus
A value from POST201
Any other value200
Unit204
none from an Opt404
raise of NotFound or Missing404
raise of Conflict, Exists or AlreadyExists409
raise of Forbidden or Denied, Unauthorized, BadRequest or Invalid, TooMany or RateLimited403, 401, 400, 429
raise of any other variant422
A body that doesn’t decode or breaks a refinement400, before the handler runs

Records are JSON objects, a variant is "Name" or {"tag": "Name", ...}, Opt is the value or null, and newtypes are their inner value. Stop the server with Ctrl-C.

Next

Migrations and hot swap changes the stored type while the service runs. The deploy model and ADR 0016 explain how effects become IAM policies.