EK9 in Depth

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.