chapter 14

a web service

this chapter builds a small http service for the teahouse. it shows the menu, takes orders, reports the day's sales, and closes when it is told to. it is under a hundred lines, and every line of logic in it is tested without opening a socket.

the standard library's std/net/http has the shape of go's net/http, with one difference that runs through the whole chapter: a handler is a plain function. it takes a request and answers a response, and nothing is written to a connection inside it. so a test calls the handler and reads what it answered, and there is no fake connection to build.

the layout

teashop on disk
teashop/
  main.kso             the entry: serve on port 8080 until closed
  day.kso              a second entry: one scripted day, for this chapter
  customer.sh          the script that plays the customer
  shop/
    menu.kso           prices, and reading an order
    server.kso         the handler and what it carries
    server_test.kso
    day.kso            serving: the scripted day and the real thing

a project may hold more than one entry. kanso run . starts main.kso, and kanso run day.kso starts the other one. both import the same module.

the menu

teashop/shop/menu.kso
import "std/text"

pub type sale
  item
  qty
  yen

pub menu = "dango 350\ntaiyaki 500\n"

fn price "dango"
  350

fn price "taiyaki"
  500

fn price _
  none

# "dango 2" is two dango. Anything else is not an order.
pub fn order_of body
  words = text/split (text/trim body) " "
  return none if length words != 2
  priced words[1] (text/to_int words[2])

fn priced _ none
  none

fn priced item qty
  counted item qty (price item)

fn counted _ _ none
  none

fn counted item qty each
  sale item qty (each * qty)

an order arrives as the body of a request, such as dango 2. order_of answers a sale when it can read one and none when it cannot. the body might not be two words, the quantity might not be a number, and the item might not be on the menu. each of those is an arm that takes a none.

the reading is split across two functions, priced and counted, and the split is forced. the first version had one function with an arm for each mistake:

tie_check.kso
# The first try at reading "dango 2": one function, three arms, and the two
# ways an order can be wrong as two of them.
fn counted _ none _
  none

fn counted _ _ none
  none

fn counted item each qty
  "{qty} {item}: {each * qty} yen"
kanso check tie_check.kso
error[dispatch]: these `counted` arms tie: each is the more specific one somewhere, and a call could match both — write the arm that is most specific in every position
  --> tie_check.kso:6:4
   6 | fn counted _ _ none
          ^

a call where both the price and the quantity are none matches both of the first two arms, and neither arm is more specific than the other everywhere, so dispatch has no way to choose. the compiler refuses the pair rather than picking by position. checking the quantity in one function and the price in the next asks one question per function, and no call can match two arms.

the handler

a server answers many requests, and it often needs to remember something between them. this one counts the requests it has answered and keeps the sales. in a language without mutation the memory is a value handed from each request to the next. the handler takes the request and the current value and answers the response and the next value. std/net/http calls the pair a turn, built with http/reply, and the value the shop carries is a till:

teashop/shop/server.kso
import "std/list"
import "std/net/http"

# What the shop knows between requests: how many it has answered and what it
# has sold. The server hands it from one request to the next.
pub type till
  requests
  sales

pub opening = till 0 []

# One request in, one response out, and the till carried to the next request.
pub fn handle (bad_request reason) t
  http/reply (http/text_page "not a request: {reason}\n") (seen t)

pub fn handle (request "GET" "/menu" _) t
  http/reply (http/text_page menu) (seen t)

pub fn handle (request "POST" "/order" body) t
  sold (order_of body) (seen t)

pub fn handle (request "GET" "/sales" _) t
  http/reply (http/text_page "{summary t}\n") (seen t)

pub fn handle (request "POST" "/close" _) t
  http/reply (http/text_page "closed\n") (http/stop (seen t))

pub fn handle _ t
  http/reply http/not_found (seen t)

pub fn summary (till requests sales)
  total = list/sum (list/map sales (s -> s.yen))
  "{many requests "request"}, {many (length sales) "sale"}, {total} yen"

fn many 1 word
  "1 {word}"

fn many n word
  "{n} {word}s"

fn seen (till requests sales)
  till (requests + 1) sales

fn sold none t
  http/reply (http/text_page "we do not sell that\n") t

fn sold s t
  said = "{s.qty} {s.item}: {s.yen} yen\n"
  http/reply (http/text_page said) (merge t { "sales":(push t.sales s) })

routing is dispatch. each arm of handle names a method and a path as literals in a request pattern. the arm that takes bad_request answers the bytes that did not parse as a request, which the library hands over as a value. the last arm catches everything else with http/not_found. adding an endpoint is adding an arm.

/close carries http/stop wrapped around the till. that ends the server, and the till inside is what the server answers when it is done.

every arm answers 200 except the catch-all's 404, including the one that refuses an order. that is not what a good service does: an order for something the shop does not sell should be a 400 or a 422. today std/net/http can build only a 200 or a 404 (#2209), so the refusal goes in the body.

testing it

the handler is a function, so testing it is calling it. http/parsed turns raw request text into the value a server would hand over, and the turn that comes back has the response in answer and the next till in carried:

teashop/shop/server_test.kso
import "std/net/http"

order = "POST /order HTTP/1.1\r\n\r\n"

fn body_of raw
  (handle (http/parsed raw) opening).answer.body

test_menu = body_of "GET /menu HTTP/1.1\r\n\r\n" == menu

test_an_order = body_of "{order}dango 2" == "2 dango: 700 yen\n"

test_nothing_we_sell = body_of "{order}mochi 1" == "we do not sell that\n"

test_close_stops = (handle (http/parsed "POST /close") opening).carried
  . (c -> summary c.value == "1 request, 0 sales, 0 yen")
kanso test shop
test_menu ... ok
test_an_order ... ok
test_nothing_we_sell ... ok
test_close_stops ... ok
4 passed, 0 failed

these tests run in milliseconds, need no port and cannot collide with another program on the machine. the last one checks what /close carries: the till inside the stop, with the closing request counted.

a day at the shop

the tests cover the handler. the server around it is the library's, so this section runs it with a real client. the scripted day serves on a port the operating system picks and starts a customer as a separate process. the customer is a shell script that sends six requests with curl:

teashop/customer.sh
# The day's customers, one request at a time.
at=http://127.0.0.1:$1
curl -s $at/menu
curl -s -d 'dango 2' $at/order
curl -s -d 'mochi 1' $at/order
curl -s -d 'taiyaki 1' $at/order
curl -s $at/sales
curl -s -X POST $at/close
teashop/shop/day.kso
import "std/net"
import "std/net/http"
import "std/os"

# A day at the shop. The customer is a separate process, a shell script that
# sends its requests with curl, and the last one closes the door. What the
# server answers is the till at closing.
pub fn day l
  net/port l
    .> (port -> os/start "sh" ["customer.sh" "{port}"])
    .> (_ -> http/serving l handle opening)
    .> (t -> print "closed after {summary t}")

# The shop for real: on a fixed port, until a request closes it.
pub fn open port
  print "serving on http://localhost:{port}"
    .> (_ -> http/serve_until port handle opening)
    .> (t -> print "closed after {summary t}")

net/listen 0 asks for a free port, so the day cannot collide with a server you already run. day reads the port back, starts the customer with it, and serves. http/serving folds the handler over the requests as they arrive, starting from opening, until a turn carries a stop, and then answers the till. from inside the project:

teashop/day.kso
import "./shop"
import "std/net"

net/listen 0 .> shop/day
kanso run day.kso
closed after 6 requests, 2 sales, 1200 yen

six requests, and two of the three orders were for things the shop sells: 700 yen of dango and 500 of taiyaki. the customer's own output goes to the process it started, so what you see is the server's account of the day.

the obvious way to write the scripted day is a customer inside the program, as a second green thread beside the server. today that deadlocks: a fiber waiting on net/read stops the other fibers, the server's among them (#2208). a separate process sidesteps that, and it is also how a real client behaves.

mugi the tanuki - runs the interpreter; pays for everything in boba pearls
mugi

the handler never touches a socket, so most of a service's bugs live where i can answer for them: call it with a request and look at what came back. the server is the library's loop, and the loop is the same for every handler.

serving for real

the project's main entry serves on a fixed port until a request closes it:

teashop/main.kso
import "./shop"

shop/open 8080

run it in one terminal and talk to it from another:

shell
$ kanso run .
serving on http://localhost:8080

$ curl -s localhost:8080/menu
dango 350
taiyaki 500
$ curl -s -d 'dango 3' localhost:8080/order
3 dango: 1050 yen
$ curl -s -X POST localhost:8080/close
closed

and the first terminal prints closed after 3 requests, 1 sale, 1050 yen and exits. to deploy it, build it the way chapter 13 built wordfreq: kanso build . --release from inside the project gives one binary, which runs anywhere the same operating system does and needs nothing beside it.

what you can do now

you can write an http service as a handler: a function from a request and the state the server carries to a response and the next state, with routing done by dispatch on the method and path. you can test every endpoint by calling the handler with parsed request text, and check what it carries forward as well as what it answers. and you can serve it on a free port beside a client process, or on a fixed one for real.

exercises

  1. add GET /sales/dango, answering the number of dango sold today. write its test first, then the arm.
  2. make the menu a map from item to price and serve it as json with std/json, so GET /menu answers {"dango":350,"taiyaki":500}. what does the test compare now?
  3. an order of dango 0 is accepted today and sells nothing. decide whether that is right, write the test that says so, and change menu.kso if it is not.
  4. the till grows by one record per sale and lives for the whole day. estimate how many orders a day the shop could take before that matters, then measure it with KANSO_COUNTERS=1 on a customer script that sends a thousand orders.