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/
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
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:
# 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"
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:
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:
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")
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:
# 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
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:
import "./shop"
import "std/net"
net/listen 0 .> shop/day
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.
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:
import "./shop"
shop/open 8080
run it in one terminal and talk to it from another:
$ 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
- add
GET /sales/dango, answering the number of dango sold today. write its test first, then the arm. - make the menu a map from item to price and serve it as json with
std/json, soGET /menuanswers{"dango":350,"taiyaki":500}. what does the test compare now? - an order of
dango 0is accepted today and sells nothing. decide whether that is right, write the test that says so, and changemenu.ksoif it is not. - 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=1on a customer script that sends a thousand orders.