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.delanddb.scanoperate on it, and they are effects: reading needsdb.read[Todos]and writingdb.write[Todos].svc todosmaps routes to functions. A{id}path segment becomes the parameter namedid, the JSON body becomes the one other parameter, and the result becomes the response.TodoId = new Stris a distinct type, so a plain string can’t be used as an id by accident.title: Str where _.len > 0is checked whenever aTodoor aChangeis built, including from a request body.open_countis 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:
| Result | Status |
|---|---|
| A value from POST | 201 |
| Any other value | 200 |
Unit | 204 |
none from an Opt | 404 |
raise of NotFound or Missing | 404 |
raise of Conflict, Exists or AlreadyExists | 409 |
raise of Forbidden or Denied, Unauthorized, BadRequest or Invalid, TooMany or RateLimited | 403, 401, 400, 429 |
raise of any other variant | 422 |
| A body that doesn’t decode or breaks a refinement | 400, 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.