A Web Service, Step by Step
Programs, Input and Output closed its notes-keeper on a sentence about the boundary: "Everything that arrives from outside starts as plain text ... It might be missing, or the wrong shape. The boundary is where you check, and EK9 makes sure you do." That program kept notes in a file for one person at a terminal. This chapter puts the same notes behind HTTP, for any number of callers at once, and the boundary stops being one command line and becomes every request. It is also where Collections is paid: it said that "the second volume covers JSON in full", and the notes arrive and leave as JSON.
If you know Spring Boot, Express, Flask or axum, you know the shape. A route is a function you register; state lives wherever you put it; body parsing, validation and security headers are libraries you add; and whether the pieces are wired together is found out when the first request arrives. EK9 builds the service into the language, which moves some of those decisions into the compiler and the rest into the runtime.
| The concern | Spring Boot | Express | EK9 |
|---|---|---|---|
| A route | an annotation on a method | app.get(path, fn) |
an operation or operator of a service, bound with for :/path |
| Shared state | a singleton bean | a module variable | a component; a service cannot have fields |
| Refuse bad input | validation annotations | middleware | a sanitized parameter, and typed bindings |
| Security headers | security configuration | a middleware package | the runtime, on every response |
| Wiring mistakes | at start-up | at the first request | compile errors (E12020, E12021) |
The chapter builds the service in steps, and each step is a program that was compiled and run, with the requests made by
curl from a shell. Every server binds the loopback address only and uses a port in the 8081-8095 range. To follow along,
start a server in one terminal and use another for the requests; to make the commands short, give that second terminal two
conveniences:
$ export NOTES=http://127.0.0.1:8081
$ alias notes='curl -s -w "\n%{http_code}\n"'
The alias prints the response body and then the status code on a line of its own.
Step one: the smallest service that serves
Here is a service with one route, and the program that serves it until it is told to stop.
#!ek9 defines module book.notesservice.hello defines constant HELLO_PORT <- 8083 STOP_SIGNAL <- "TERM" defines service Greeting for :/greeting open hello() as GET for :/{name} -> personName as String :=: PATH "name" <- response as HTTPResponse: (who: personName) with trait of HTTPResponse override status() as pure <- rtn as Integer: 200 override content() <- rtn as Bytes: Bytes().withUtf8(`hello ${who}, the notes service is running`) override contentType() as pure <- rtn as String: "text/plain" default operator ? defines application GreetingApp register Greeting() defines program ServeGreeting() with application of GreetingApp stdout <- Stdout() try -> server <- HTTP(NetworkProperties(port: HELLO_PORT, localOnly: true)) stopOnTerm <- (server, stdout) is SignalHandler as function result: Integer() stdout.println(`received ${signal}, stopping`) server.stop() Signals().register(STOP_SIGNAL, stopOnTerm) stdout.println(`serving on port ${HELLO_PORT}`) if server.serve() stdout.println("stopped, in-flight requests drained") stdout.println("the try has closed the server")
A service is routes bound to operations. Greeting for :/greeting open names the service and the path it lives under;
hello() as GET for :/{name} is one route, and -> personName as String :=: PATH "name" binds the {name} segment of the
path to the parameter. open declares that the service has no security gate; the help for service says only that, and the
word is there so that an ungated service is a decision somebody wrote down.
The return value is an HTTPResponse, and it is made where it is needed: (who: personName) with trait of HTTPResponse
builds a dynamic class that captures the parameter into a field called who and overrides the parts of the response it
cares about (Dynamic Functions and Classes covers what that construct is). status(), contentType() and the rest are
methods because a response is an object the server interrogates, not a value you return a number into.
The program is where the service meets the network. ServeGreeting() with application of GreetingApp is wired to the
application that registers Greeting, and the program creates one HTTP server from a NetworkProperties and calls
serve(), which blocks until something calls stop().
Run it, and from another terminal:
$ curl -s -i http://127.0.0.1:8083/greeting/Ada
HTTP/1.1 200 OK
Server:
X-frame-options: DENY
Referrer-policy: strict-origin-when-cross-origin
Permissions-policy: geolocation=(), camera=(), microphone=()
Date: Tue, 10 Nov 2026 22:15:12 GMT
Content-security-policy: default-src 'none'
Content-type: text/plain; charset=utf-8
Content-language: en
Content-length: 39
X-content-type-options: nosniff
Cache-control: public,max-age=3600,must-revalidate
hello Ada, the notes service is running
Nothing in the program set most of those headers. The runtime adds the X-frame-options, Referrer-policy,
Permissions-policy, Content-security-policy and X-content-type-options headers to every response, and supplies a
Cache-control and a Content-language when the response does not override them. The security headers are the runtime's
decision, not middleware you must remember to install, and the cost is that the defaults are the runtime's too: the way to a
different one is to override the method on the response.
The path is decoded before it reaches the parameter, so a space arrives as a space, and an address nobody mapped is a 404:
$ notes 'http://127.0.0.1:8083/greeting/Grace%20Hopper'
$ notes http://127.0.0.1:8083/nothing
hello Grace Hopper, the notes service is running
200
404 Not Found
404
Stopping it
serve() returns when stop() is called, and the thing that calls it is a signal handler registered with
Signals().register("TERM", stopOnTerm). The handler is a dynamic function that captures the server and stdout, and its
first line, result: Integer(), is the most important one: a SignalHandler that assigns a set result ends the process
with that exit code, as the UDP server in Networking: TCP and UDP did, and an unset result means "I have dealt with it,
carry on". This one stops the server and lets serve() return, so the try can close it. Sending the process TERM
produced:
serving on port 8083
Nov 10, 2026 10:15:12 PM org.ek9.lang.HTTP doStart
INFO: EK9 HTTP server listening on /127.0.0.1:8083
received TERM, stopping
Nov 10, 2026 10:15:12 PM org.ek9.lang.HTTP lambda$stop$0
INFO: EK9 HTTP server stopped serving
stopped, in-flight requests drained
the try has closed the server
The two INFO: lines are java.util.logging on standard error, written by the HTTP runtime on every start and stop;
the other lines are the program's. stop() drains requests that are already in flight, for up to five seconds, before
serve() returns true.
Leave out the assignment to result, and the compiler refuses the handler:
stopOnTerm <- (stdout) is SignalHandler as function stdout.println(`received ${signal}, stopping`)
EK9 : Error : E08050: '(' on line 9 position 20: 'result': return value is not always initialised
See: ek9 -h E08050
A dynamic function that implements a function type inherits that type's returning variable, but its body replaces the super's body, so every path must assign it. There is no default of "carry on"; the choice between exiting and continuing is one you state.
Wiring, checked both ways
A service nobody serves is a quiet failure in many frameworks: the routes are registered, the process runs, and every
request is a 404. EK9 checks the pairing in both directions. A program that uses an application which registers a service,
and creates no HTTP:
ServeNotes() with application of NotesApp Stdout().println("serving notes")
EK9 : Error : E12020: 'ServeNotes' on line 29 position 4: program 'ServeNotes' uses application 'NotesApp' which registers services, but creates no HTTP: application registers services but program has no HTTP server
See: ek9 -h E12020
and the other way round, an HTTP server over an application that registers no service:
ServeNothing() with application of LogApp try -> server <- HTTP(NetworkProperties(port: 8081, localOnly: true)) server.serve()
EK9 : Error : E12021: 'server' on line 27 position 11: program 'ServeNothing' creates HTTP but application 'LogApp' has no service registrations: program creates HTTP but application has no service registrations
See: ek9 -h E12021
A third rule completes it: an HTTP in a program with no with application of linkage is E12022, and a second HTTP in
one program is E12023. The limit of the check is what it can see. It proves that a program that serves is wired to a
service, not that the routes in the service are the ones you meant, which the later steps show.
Step two: the model
The notes domain is a record, a message table and nothing else.
defines record Note id <- Integer() text <- String() default Note() as pure Note() as pure -> noteId as Integer noteText as String id :=? Integer(noteId) text :=? String(noteText) default operator ? default operator :=: default operator $$
The record is Volume 1's, in the shorter field style. Its default operator $$ is the interesting line: $$ means "this as
JSON", and a default writes it from the fields. A note's JSON form is an object with its two fields, and a list of notes is
an array of them; the chapter returns to $$ below. default operator :=: makes a note copyable, and default operator ? says that a note is set
when both of its fields are.
The words the service speaks are not in the logic. They are a text construct:
defines text for "en" NoteMessages noSuchNote() -> noteId as Integer `there is no note numbered ${noteId}` notJson() `the body is not valid JSON` needsText() `a note needs a non-empty "text" field`
NoteMessages("en") selects the English block, and noSuchNote(noteId) is a method that returns the text with the number
interpolated. A second locale is a second defines text for "fr" block with the same names; the compiler checks that the
blocks agree (Speaking Every Language in Volume 1). The service below only ever asks for "en".
Step three: state behind a lock, behind a component
A service has no fields; the grammar forbids them. The help for service gives the reason in a line: services are
stateless so that they can be scaled out without split state, and any state belongs in components, which are managed
singletons. So the notes live in a plain class, NoteBook, which knows nothing about threads:
defines class NoteBook notes <- Dict() of (Integer, Note) lastId <- 0 add() -> noteText as String <- rtn <- Note() lastId++ rtn: Note(lastId, noteText) notes += DictEntry(rtn.id, rtn) find() -> noteId as Integer <- rtn <- Optional() of Note if notes contains noteId rtn: Optional(notes.getOrDefault(noteId, Note()))
and are reached through a component, a NoteRepository with an abstract contract and one implementation that owns the
lock:
LockedNoteRepository extends NoteRepository lockedBook as MutexLock of NoteBook: MutexLock(NoteBook()) override add() -> noteText as String <- rtn <- Note() accessKey <- (noteText, rtn) is MutexKey of NoteBook as function rtn :=: lockedItem.add(noteText) require lockedBook.enter(accessKey)
MutexLock of NoteBook is the only door to the book. enter takes a MutexKey, a function that is handed the locked item
as lockedItem, and the dynamic function accessKey captures what it needs (the argument and the return variable) and does
its work inside the lock. The other five methods have exactly this shape, so the book has no unlocked path. Concurrency,
Safely is the chapter on MutexLock and on what the compiler checks about locks.
A service gets the component it needs by declaring it with a ! where it is used. That is the whole of injection at the point
of use:
repository as NoteRepository!
The application decides which implementation that is (Dependency Injection and Applications covers the registry), so a
test can register a different one without the service noticing. A sanity check that the lock does its job: forty POST
requests fired at once from the shell, then the notes counted.
$ for n in $(seq 1 40); do curl -s -o /dev/null -X POST -d "{\"text\": \"note $n\"}" $NOTES/notes & done; wait
$ curl -s $NOTES/notes | grep -o '"id":[0-9]*' | sort -u | wc -l
$ curl -s $NOTES/notes | grep -o '"id":[0-9]*' | sort -t: -k2 -n | tail -1
40
"id":40
Forty distinct ids, the highest being 40. That shows the lock works on this run; it is not a proof, and what EK9 proves about locks is the scope Concurrency, Safely states.
Step four: a response that already knows its status
This is a step that is easy to get wrong, and it is worth a measured example. A response object is interrogated by the server for its status and headers, and only then for its content. So a response that works out its status while it builds its content has decided too late. Here is a response that does exactly that:
LateResponse with trait of HTTPResponse code as Integer: STATUS_OK override status() as pure <- rtn as Integer: code override content() <- rtn as Bytes: Bytes() code := STATUS_NOT_FOUND rtn: Bytes().withUtf8("there is no such note") override contentType() as pure <- rtn as String: "text/plain" default operator ?
status() answers from the field code; content() finds there is no such note and sets code to 404. The program compiles,
and it is served on port 8084:
$ curl -s -i http://127.0.0.1:8084/late
HTTP/1.1 200 OK
Server:
X-frame-options: DENY
Referrer-policy: strict-origin-when-cross-origin
Permissions-policy: geolocation=(), camera=(), microphone=()
Date: Tue, 10 Nov 2026 22:20:38 GMT
Content-security-policy: default-src 'none'
Content-type: text/plain; charset=utf-8
Content-language: en
Content-length: 21
X-content-type-options: nosniff
Cache-control: public,max-age=3600,must-revalidate
there is no such note
The status line says 200 OK; the body says the note does not exist. The compiler cannot see this, because every method is
legal. The rule the rest of the service follows is therefore a convention that costs a little ceremony: an operation decides,
and builds a response that reports what was decided. The operation does the work, picks the status, and only then calls a
function that makes a response with that status already in it. Every response in this service says its own status.
reply() -> status as Integer body as String location as String <- response as HTTPResponse: (status: status, body: body, location: location) with trait of HTTPResponse override status() as pure <- rtn as Integer: status override content() <- rtn as Bytes: body? <- Bytes().withUtf8(body) else Bytes() override contentType() as pure <- rtn as String: "application/json" override cacheControl() as pure <- rtn as String: "no-store" override contentLocation() as pure <- rtn as String: location default operator ? answer() -> status as Integer body as String <- response as HTTPResponse: reply(status, body, String()) created() -> body as String location as String <- response as HTTPResponse: reply(STATUS_CREATED, body, location) problem() -> status as Integer message as String <- response as HTTPResponse: answer(status, $${"error": message})
reply is the one place that builds a response: a status, a JSON body and an optional location, captured by name. The
three functions below it are the cases: answer for a status and a body, created for a 201 that carries the new note's
address, and problem, which wraps a message as a JSON object, {"error": ...}. A Dict has a $$ too, so the message is
escaped by the same operator that writes the notes, and there is no string concatenation anywhere near a quote. An empty body,
as in a 204, is Bytes() and is chosen with the usual set test, body?.
Step five: JSON in, JSON out
Three things in the service touch JSON: the notes going out, the body coming in, and the error. Going out is $$. Coming in
is JSON().parse, which does not throw: it returns a Result of (JSON, String), the same type you met in Optional and
Result, in Depth, with the parser's complaint on the error side. A program that exercises the whole surface in one place:
#!ek9 defines module book.notesservice.jsondocuments defines constant TEXT_FIELD <- "text" TAGS_FIELD <- "tags" MISSING_FIELD <- "pinned" defines record Note id <- Integer() text <- String() default Note() as pure Note() as pure -> noteId as Integer noteText as String id :=? Integer(noteId) text :=? String(noteText) default operator ? default operator $$ defines function textOf() as pure -> document as JSON field as String <- rtn as String: cat [document.get(field)] | collect as String describe() -> document as JSON stdout <- Stdout() noteText <- document.get(TEXT_FIELD) stdout.println(`as JSON: ${noteText}`) stdout.println(`as text: ${textOf(document, TEXT_FIELD)}`) tags <- document.get(TAGS_FIELD) stdout.println(`tags: ${tags.arrayLength()} of them`) for tag in tags stdout.println(`tag: ${textOf(tag, TEXT_FIELD)}`) stdout.println(`pinned set: ${document.get(MISSING_FIELD)?}`) defines program JsonDocuments() stdout <- Stdout() body <- "{\"text\": \"buy \\\"oat\\\" milk\", \"tags\": [{\"text\": \"home\"}, {\"text\": \"urgent\"}]}" parsed <- JSON().parse(body) if parsed.isOk() describe(parsed.ok()) broken <- JSON().parse("{not json") stdout.println(`broken: ok ${broken.isOk()} error ${broken.isError()}`) nothing <- JSON().parse("") stdout.println(`empty body: ok ${nothing.isOk()} error ${nothing.isError()}`) array <- JSON().parse("[1, 2]") if array.isOk() stdout.println(`an array has a text field: ${array.ok().get(TEXT_FIELD)?}`) stdout.println($$Note(1, "milk")) stdout.println($$[Note(1, "milk"), Note(2, "bread")]) stdout.println($$Note())
as JSON: "buy \"oat\" milk"
as text: buy "oat" milk
tags: 2 of them
tag: home
tag: urgent
pinned set: false
broken: ok false error true
empty body: ok true error false
an array has a text field: false
{"id":1,"text":"milk"}
[{"id":1,"text":"milk"},{"id":2,"text":"bread"}]
{"id":null,"text":null}
Read it in order. document.get("text") answers a JSON, not a String, and printing it gives the JSON form, quoted and
escaped. To get the text the property holds, the helper streams the one value into a String: cat [document.get(field)] | collect as String. String(json) is not the way, because it gives the JSON form too. A JSON array iterates with for, and
arrayLength() counts it. get of a property that is not there answers an unset JSON, so a missing field is tested with ?
like any other absence; get on an array, or on a scalar, answers unset the same way.
The last four lines are the edges that matter at a boundary. Malformed text is an error Result, as expected. An empty
body is not an error: it parses, as a document with nothing in it, and the first thing to fail is the check for the field
you wanted, which is why the service answers an empty POST with 422 and not 400. And $$ of an unset record writes
the JSON text null for each unset field. The service never emits that, because it only serialises notes it has just made or found.
The service's use of the Result follows the rule from Optional and Result, in Depth: the error side is read only inside a
check of the error side. Here it is not read at all, because the 400 does not need the parser's message.
textOf() -> document as JSON field as String <- rtn as String: cat [document.get(field)] | collect as String linkTo() -> noteId as Integer <- rtn <- String() base <- Networking().urlFromEnv(BASE_URL_NAME) if base? link <- base.resolve("notes").withPathSegment($noteId) rtn: $link
addNote() -> repository as NoteRepository body as String <- response as HTTPResponse? parsed <- JSON().parse(body) if parsed.isOk() noteText <- textOf(parsed.ok(), TEXT_FIELD) if noteText? saved <- repository.add(noteText) response: created($$saved, linkTo(saved.id)) else response: problem(STATUS_UNPROCESSABLE, NoteMessages("en").needsText()) else response: problem(STATUS_BAD_REQUEST, NoteMessages("en").notJson())
addNote is the whole decision tree of a POST, and its three outcomes are three statuses: a body that is not JSON is a 400,
a document with no usable text is a 422 (the syntax was fine; the content was not), and a good one is saved and answered
with a 201 whose Content-location is built by linkTo. linkTo takes the address from PUBLIC_BASE_URL, the base the
service is deployed at, and derives the note's address with resolve and withPathSegment, as in Networking: TCP and UDP.
It never uses the Host header of the request, which is whatever the caller sent. The else of isOk() is deliberate
rather than lazy: else of an ok-check is the correct place for "anything that did not parse".
Step six: the routes
All the logic is in functions, so the service is only a binding layer: it says which part of the request becomes which argument, and calls one function.
defines service Notes for :/notes open search() as GET for :/search -> query as sanitized String :=: QUERY "query" <- response as HTTPResponse? repository as NoteRepository! found <- repository.matching(query) response: answer(STATUS_OK, $$found) list() as GET for :/ <- response as HTTPResponse? repository as NoteRepository! notes <- repository.all() response: answer(STATUS_OK, $$notes) byId() as GET for :/{id} -> noteId as Integer :=: PATH "id" <- response as HTTPResponse? repository as NoteRepository! response: showNote(repository, noteId) operator += for :/ -> body as String :=: CONTENT <- response as HTTPResponse? repository as NoteRepository! response: addNote(repository, body) operator :^: for :/{id} -> noteId as Integer :=: PATH "id" body as String :=: CONTENT <- response as HTTPResponse? repository as NoteRepository! response: replaceNote(repository, noteId, body) operator -= for :/{id} -> noteId as Integer :=: PATH "id" <- response as HTTPResponse? repository as NoteRepository! response: removeNote(repository, noteId)
A route is either a named operation with its verb, search() as GET for :/search, or an operator, whose symbol is the
verb: += is a POST, :^: (replace) is a PUT, :~: (merge) is a PATCH and -= is a DELETE. The service maps
+=, :^: and -=, and does not map :~:. The binding on each parameter says where it comes from: PATH "id" is the
{id} segment, QUERY "query" the query parameter, CONTENT the request body, and HEADER and REQUEST the headers and
the whole request. A parameter's type is part of the binding: noteId as Integer :=: PATH "id" means a path that is not a
whole number never reaches the operation.
The program
defines application NotesApp register LockedNoteRepository() as NoteRepository register Notes() defines program ServeNotes() with application of NotesApp stdout <- Stdout() try -> server <- HTTP(NetworkProperties(port: SERVICE_PORT, localOnly: true)) stopOnTerm <- (server, stdout) is SignalHandler as function result: Integer() stdout.println(`received ${signal}, stopping`) server.stop() Signals().register(STOP_SIGNAL, stopOnTerm) stdout.println(`serving notes on port ${SERVICE_PORT}`) if server.serve() stdout.println("stopped, in-flight requests drained") stdout.println("the try has closed the server")
NotesApp registers the repository as a NoteRepository, which is what repository as NoteRepository! asks for, and the
service. The program is step one's again, serving the notes service on its own port. Start it with the settings it reads from
its environment:
$ PUBLIC_BASE_URL=http://127.0.0.1:8081/ EK9_SANITIZER_LOG_FORMAT=SIMPLE ek9 NotesService.ek9
Running it
An empty list, then a POST, shown with its headers, because the status and the Content-location are the point:
$ notes $NOTES/notes
$ curl -s -i -X POST -d '{"text": "buy milk"}' $NOTES/notes
[]
200
HTTP/1.1 201 Created
Server:
X-frame-options: DENY
Referrer-policy: strict-origin-when-cross-origin
Permissions-policy: geolocation=(), camera=(), microphone=()
Date: Tue, 10 Nov 2026 22:15:15 GMT
Content-security-policy: default-src 'none'
Content-type: application/json; charset=utf-8
Content-language: en
Content-length: 26
Content-location: http://127.0.0.1:8081/notes/1
X-content-type-options: nosniff
Cache-control: no-store
{"id":1,"text":"buy milk"}
The response overrides Cache-control with no-store, so the runtime's default of an hour is not used. Then a second note,
the list, one note by number, and a number nobody has:
$ notes -X POST -d '{"text": "buy bread"}' $NOTES/notes
$ notes $NOTES/notes
$ notes $NOTES/notes/1
$ notes $NOTES/notes/9
{"id":2,"text":"buy bread"}
201
[{"id":1,"text":"buy milk"},{"id":2,"text":"buy bread"}]
200
{"id":1,"text":"buy milk"}
200
{"error":"there is no note numbered 9"}
404
The three ways a POST can be wrong, each with the status the decision tree gives it:
$ notes -X POST -d '{not json' $NOTES/notes
$ notes -X POST -d '{"title": "x"}' $NOTES/notes
$ notes -X POST $NOTES/notes
{"error":"the body is not valid JSON"}
400
{"error":"a note needs a non-empty \"text\" field"}
422
{"error":"a note needs a non-empty \"text\" field"}
422
Replace a note, replace one that is not there, and delete:
$ notes -X PUT -d '{"text": "buy oat milk"}' $NOTES/notes/1
$ notes -X PUT -d '{"text": "x"}' $NOTES/notes/9
$ notes -X DELETE $NOTES/notes/2
$ notes -X DELETE $NOTES/notes/2
$ notes $NOTES/notes
{"id":1,"text":"buy oat milk"}
200
{"error":"there is no note numbered 9"}
404
204
{"error":"there is no note numbered 2"}
404
[{"id":1,"text":"buy oat milk"}]
200
The first DELETE answers 204 with no body, which is the empty line before the status; the second finds nothing and says so.
What the runtime answers for you
Some responses in a session are not written anywhere in the program. A path that is not a whole number, a PATCH to a
route that has no PATCH, and a trailing slash:
$ notes $NOTES/notes/abc
$ notes $NOTES/notes/
$ curl -s -i -X PATCH -d '{"text": "x"}' $NOTES/notes/1
{"error":"Bad Request","status":400,"detail":"Parameter 'noteId' cannot be converted to org.ek9.lang::Integer","parameter":"noteId","source":"PATH"}
400
404 Not Found
404
HTTP/1.1 405 Method Not Allowed
Server:
X-frame-options: DENY
Referrer-policy: strict-origin-when-cross-origin
Permissions-policy: geolocation=(), camera=(), microphone=()
Date: Tue, 10 Nov 2026 22:15:15 GMT
Allow: GET, PUT, DELETE
Content-security-policy: default-src 'none'
Content-type: text/plain; charset=utf-8
Content-length: 22
X-content-type-options: nosniff
405 Method Not Allowed
The 400 is the runtime's, in JSON, naming the parameter and where it was bound from. The trailing slash is a different
address: a route for :/ under Notes for :/notes is /notes exactly, and /notes/ is a 404. Trailing slashes are
significant everywhere. The 405 carries an Allow header that the runtime worked out from the operations mapped on that path,
which is why it lists GET, PUT, DELETE and not POST.
A parameter you declare as QUERY or HEADER is mandatory. There is no optional query parameter in the binding; a request
without it is a 400 of exactly the shape above. If a parameter is optional, the way to say so is a second route.
Route order, and the mistake the compiler cannot see
The compiler checks routes for the same shape: two GET routes under one path, each with one path variable, are
duplicates whatever the variables are called and whatever their types, and both are reported as E02070:
byNumber() as GET for :/{number} -> number as Integer :=: PATH "number" <- response as HTTPResponse: plain(`note number ${number}`) byName() as GET for :/{name} -> name as String :=: PATH "name" <- response as HTTPResponse: plain(`note named ${name}`)
EK9 : Error : E02070: 'byNumber' on line 19 position 6: HTTP verb: 'GET' ':/{number}' and ':/{name}': PATH is duplicated in terms of structure/naming
See: ek9 -h E02070
EK9 : Error : E02070: 'byName' on line 23 position 6: HTTP verb: 'GET' ':/{number}' and ':/{name}': PATH is duplicated in terms of structure/naming
See: ek9 -h E02070
What it does not check is a literal route after a variable one. These shapes differ, so both compile; but the first route declared that matches wins, and a literal segment does not beat a variable. Here the search route comes after the numeric one:
#!ek9 defines module book.routeorder defines constant ORDER_PORT <- 8082 defines function plain() -> text as String <- response as HTTPResponse: (text) with trait of HTTPResponse override status() as pure <- rtn as Integer: 200 override content() <- rtn as Bytes: Bytes().withUtf8(text) override contentType() as pure <- rtn as String: "text/plain" default operator ? defines service Words for :/words open byNumber() as GET for :/{number} -> number as Integer :=: PATH "number" <- response as HTTPResponse: plain(`word number ${number}`) search() as GET for :/search -> query as String :=: QUERY "query" <- response as HTTPResponse: plain(`searching for ${query}`) defines application WordsApp register Words() defines program ServeWords() with application of WordsApp try -> server <- HTTP(NetworkProperties(port: ORDER_PORT, localOnly: true)) server.serve()
$ notes http://127.0.0.1:8082/words/42
$ notes 'http://127.0.0.1:8082/words/search?query=pear'
word number 42
200
{"error":"Bad Request","status":400,"detail":"Parameter 'number' cannot be converted to org.ek9.lang::Integer","parameter":"number","source":"PATH"}
400
/words/search matched /{number}, failed to become an Integer, and was refused; the search route is unreachable and the
compiler said nothing. The notes service avoids it by declaring search first. This is a real gap, not a feature: the order
of the declarations is part of the behaviour, and the only check is a request.
Input in, secrets out
A web boundary is where the two directions of trust meet, and EK9 has a language feature for each.
Inbound: sanitized
The search route takes query as sanitized String :=: QUERY "query". A sanitized parameter is cleaned by the receiver, on
entry, whatever route calls it; a value that carries a threat arrives as an unset String, and a safe one as the receiver's
own copy. In a service the binding does the rest: an unset parameter cannot be bound, so the request is refused before the
operation's body runs. A clean query, a missing one, a SQL injection and a script:
$ notes "$NOTES/notes/search?query=milk"
$ notes $NOTES/notes/search
$ notes -G $NOTES/notes/search --data-urlencode "query=' OR 1=1 --"
$ notes -G $NOTES/notes/search --data-urlencode "query=<script>alert(1)</script>"
[{"id":1,"text":"buy oat milk"}]
200
{"error":"Bad Request","status":400,"detail":"Parameter 'query' cannot be converted to org.ek9.lang::String","parameter":"query","source":"QUERY"}
400
{"error":"Bad Request","status":400,"detail":"Parameter 'query' cannot be converted to org.ek9.lang::String","parameter":"query","source":"QUERY"}
400
{"error":"Bad Request","status":400,"detail":"Parameter 'query' cannot be converted to org.ek9.lang::String","parameter":"query","source":"QUERY"}
400
The missing query, the injection and the script are the same 400. That is the cost: the caller is not told why. The
reason is in the server's log, one line per threat
(EK9_SANITIZER_LOG_FORMAT=SIMPLE chose this plain form; the default is JSON with a timestamp):
[SQL_INJECTION] Dangerous input detected: ' OR 1=1 --
[SQL_INJECTION_SIGNATURE] Dangerous input detected: ' OR 1=1 --
[XSS] Dangerous input detected: <script>alert(1)</script>
The service does not mark the note text sanitized, and that is a decision, not an omission: a note is data that is stored and
returned inside a JSON string that the service escapes, so <script> is a legitimate note and a refused one would be a bug.
The query is different, because it is compared against stored text. sanitized is a tool for the places where text becomes
something else, and it is the author's judgement which places those are.
Outbound: Sensitive
The other direction is a credential the service holds. Suppose the notes are synchronised to another service with a key. A
Sensitive wraps it so that it cannot be printed, interpolated or serialised by accident, and it cannot be made from a
String at all: the only way to a set one is EnvVars().sensitiveGet("NAME").
#!ek9 defines module book.notesservice.synckey defines constant SYNC_KEY_NAME <- "NOTES_SYNC_KEY" defines class NoteSync with trait of Privileged syncKey as Sensitive? NoteSync() -> key as Sensitive syncKey: key default private NoteSync() authorisation() <- rtn as String: String() if syncKey? rtn: authorisationFor(syncKey.reveal()) default operator ? defines function authorisationFor() as pure -> header as String <- rtn as String: `Bearer header of ${length header} characters` defines program ShowSyncKey() stdout <- Stdout() key <- EnvVars().sensitiveGet(SYNC_KEY_NAME) if key? sync <- NoteSync(key) stdout.println(`key: ${key}`) stdout.println(`as JSON: ${$$key}`) stdout.println(sync.authorisation()) else stdout.println(`${SYNC_KEY_NAME} is not set`)
With the variable set, and then unset:
$ NOTES_SYNC_KEY=example-key-value-1234 ek9 SyncKey.ek9
$ ek9 SyncKey.ek9
key: ***REDACTED***
as JSON: "***REDACTED***"
Bearer header of 22 characters
NOTES_SYNC_KEY is not set
$ and $$ of a Sensitive both answer ***REDACTED***, so interpolating it into a log line or a response cannot leak it.
The program can still use the key, because NoteSync has the Privileged trait, and reveal() is legal only in a method of a
class or trait that is Privileged:
describe() <- rtn as String: String() if syncKey? rtn: syncKey.reveal()
EK9 : Error : E11090: 'reveal' on line 18 position 23: 'SyncReport': reveal() on Sensitive is only permitted in a method of a class or trait that is Privileged: it has the Privileged trait, a trait that has it, or a super class that has it
See: ek9 -h E11090
So the places a secret can be read are the places that say so in their declaration, and a code review can find them with a
search for Privileged. A function can never be Privileged, because a function has no traits. And the compiler looks at the
source for the key as well. A key written into the program is refused, and the diagnostic does not print it:
syncKey <- "sk_test_abcdefghijklmnopqrstuvwxyz"
EK9 : Error : E11086: '"sk_test_abcdefghijklmnopqrstuvwxyz"' on line 7 position 17: 'sk_tes...***' hardcoded API key detected
See: ek9 -h E11086
The catalogue has sibling codes for other credential shapes; ek9 -h E11086 lists them. What this does not claim is a run-time
scrubber: nothing here rewrites a credential that is already inside an ordinary String on its way to a log. The protection is
the type, the gate on reveal() and the check of the source.
What the capstone costs
You give up breadth. There is one HTTP server, and its behaviour is the runtime's: you do not choose the engine, the security
headers or the logging. A response is assembled by hand, as reply shows, which is more typing than returning an object from a
framework and having it serialised. A service has no fields, so every piece of state costs a component and its contract, and a
lock if it is shared. Routes bind first-match, with a check only for the same shape. A declared query or header is mandatory.
And the ecosystem around a framework is not part of this chapter or of its claims: persistence, templating and
authentication are things this service does not do.
What the cost buys is that the dull bugs of a service are not available. A service without a server, or a server without a
service, does not compile. A status decided too late is a convention here, but a response with no status is not a program. An
input that carries a threat never reaches the code that would have trusted it, and a secret has one declared door. Shared
state is reached only through a MutexLock. Packaging and Sharing Your Program covers the same posture one level up: a
publishable package declares the system powers it uses, so what a dependency may touch is written down before it runs.
A design note. I would rather the compiler refused to start a service that could not work than that it started one and waited for somebody to find out. A route registered and never served, a server with nothing to serve and a handler that does not say whether the process should exit are all ordinary mistakes with a quiet failure, and each is a compile error here because a person who is late for a deadline will not go and look for them. - Steve
What the notes-keeper became
The Volume 1 program read argv, kept a list in a file and printed it. This one reads a request, keeps a list in a locked book
and answers in JSON. Its record, its text, its Optional, its component, its dynamic functions and its application are all
things you met on the way here, and the promise at the boundary is the same one: what arrives is checked, and the compiler is
what makes you.