17. Lambda handlers from a service model
Twelve Lambda handlers and their index from one service model, by a rule set written twice: as canonical aontu, and as a Lambda handler with its aontu on marked lines
Exercises replace with no hole syntax, esc: sq, verbatim whitespace, the empty-selection conditional, each order and the split-form-join chain, the template surface and its round trip, both-ports byte parity
Rendered from
use-cases/17-lambda-handlers/README.md
in the engine repository. The models, the expected output and the
check.sh that drives the CLI over all of it are in
use-cases/17-lambda-handlers/.
Scenario
A backend of twelve services on one message wire, each deployed as its
own Lambda handler. Every handler is the same forty lines with three
things that vary per service: the patterns it listens on, the patterns
it calls out to, and whether it picks up files from S3. The handlers
are generated, and the generator is the handler file itself: one
rule set whose body is the file, line for line, with three nested
dispatches where the file varies. It is here twice, in the two forms
of one generator: gen.aon, the canonical aontu, and handler.ts, the
same thing written as a Lambda handler with its aontu on marked lines. Both answer the same thirteen files.
Nothing in the mechanism is about handlers. emit, match, replace,
body, esc and each are the whole vocabulary; what makes this
produce Lambda handlers is the target text in the body.
The model tree
model.aon is one map of services; each has its listen and client
pins and its on.file.events, empty where it has nothing to say,
because the generator reads all three and a dispatch over an empty
selection emits nothing.
$
└── services
├── admin (3)
├── audit (3)
├── billing (3)
├── chat (3)
├── embed (3)
├── export (3)
├── index-build (3)
├── ingest (3)
├── notify (3)
├── parse (3)
├── search (3)
└── summary (3)
aontu view doc --depth 2 model.aon draws it, and check.sh pins it
with --out --check.
The generator
gen.aon includes the model, gives each service its name
(svc: $.services & pack($.services, { name:key() })), and declares
the rule set:
%handler = emit(_, {
match: name: string
esc: sq
replace: SERVICE: .name
body: [
"import { getSeneca } from '../../env/lambda/lambda'"
""
"function complete(seneca: any) {"
emit(.listen, {
match: pin: string
esc: sq
replace: PIN: .pin
body: [" seneca.listen({type:'sqs',pin:'PIN'})"]
})
# ... the client pins, and the S3 hook over filter(.on.file.events, { source: s3 })
"}"
""
"exports.handler = async ("
" event:any,"
" context:any"
") => {"
" "
" let seneca = await getSeneca('SERVICE', complete)"
# ...
]
})
Four things to read off it:
- A value reaches a line through
replace, not a hole.SERVICEandPINare ordinary TypeScript in the body; the map says which exact strings stand for a value, and the value is evaluated against the matched node. There is no delimiter to collide with the target’s own syntax, which is why a deployment template’s${self:…}or a backtick string survives. - Escaping is on.
esc: sqnames the single-quoted convention every value is escaped by. The chat service listens onsys:chat,user:o'brien, and the handler carriespin:'sys:chat,user:o\'brien', the line the compiler would otherwise reject. - A line is verbatim. The two lines that are two spaces and nothing else, and the two blank lines, are in every handler because they are in the body. There are no trim markers, because nothing leaks: a rule is not an output line.
- The conditional is the selection. A service with no S3 events
gets no gateway hook, because
filter(.on.file.events, { source: s3 })selects nothing and a dispatch over nothing emits nothing.
The file list is one dispatch over two parts, the service map and an
index marker: each service becomes a handlers/<name>.ts, and
index.ts names every service in the model’s order with a constant
spelled by the name-derivation chain, join(each(split(_, "-"), upper(_)), "_"), so index-build is INDEX_BUILD. each keeps the
order where a pack would sort, and the byte gate holds all thirteen
files against expected/.
The same generator, in the target’s own syntax
handler.ts is gen.aon again, as a template: a marked line is
aontu source, and every other line is a line of output. The marker
is TypeScript’s comment token plus a dash, so the file is a Lambda
handler: tsc parses it, an editor highlights it, and the body lines are
the handler’s own text at the indentation they land on.
//- %handler = emit(_, {
//- match: { name:string }
//- esc: sq
//- replace: { SERVICE: .name }
//- body: [
import { getSeneca } from '../../env/lambda/lambda'
function complete(seneca: any) {
//- emit(.listen, { match:{ pin:string }, esc:sq, replace:{ PIN: .pin }, body: [
seneca.listen({type:'sqs',pin:'PIN'})
//- ]})
The same thirteen files come out of it: the entry’s extension decides
that it is a template, and it is desugared before it is evaluated, by
whichever verb reads it. aontu template handler.ts prints the
canonical form, which is gen.aon’s body with each output line quoted,
and aontu template --check handler.ts holds the file to the spelling
the round trip answers. Its whitespace is output, so the byte gate
against expected/ is what holds the bytes.
What check.sh proves
- Twelve handlers under
expected/handlers/andexpected/index.tsmatch their goldens byte for byte. - All thirteen files parse as TypeScript, by the compiler’s own parser with no diagnostics.
expected/handlers/chat.tscarriespin:'sys:chat,user:o\'brien':esc: sqescaped the apostrophe for the literal it lands in.expected/handlers/notify.tshas exactly two lines of two spaces and two blank lines: whitespace is verbatim.ingest.tshas the S3 gateway hook forsys:ingest,cmd:file,notify.tshas none, andindex-build.tshooks its S3 event and not its SQS event.expected/index.tsis twelve lines in the model’s order, starting withadmin, and carriesexport const INDEX_BUILD = 'index-build'.bad/overlap.aon(the keyPinsidePIN) is refused with[aontu/replace_overlap], andbad/unused.aon(a key the body does not hold) with[aontu/replace_unused], both before any node is visited.- The byte gate against a copy of the goldens with one handler edited
by hand is red, exit 1, naming
handlers/chat.ts. aontu tracecarries an entry for every stamped piece: each names the file it reached and the rule that wrote it, the%handlerset is addressed by the name it was read through, and the twelve services are each matched at their own path in the model.handler.ts, the template form, answers the same thirteen files, round-trips as a fixpoint undertemplate --check, and parses as TypeScript: the generator is a file in the language it generates.- The Go port builds the same thirteen files byte for byte and refuses the same seeded template (skipped with a note when no Go toolchain is present).
- The Go port records the same trace, entry for entry.
- The Go port desugars the template form and builds it identically.
- The model tree draws and is pinned, text and SVG.
Running it
From this directory, ./check.sh runs all 14 assertions and exits 0.
It drives the TypeScript CLI (ts/bin/aontu.js, or the command in
$AONTU) and, when go is on the path, the Go CLI built from go/.
The verb by hand:
aontu model get out gen.aon # the tree, thirteen files in it
aontu trace gen.aon # what rule wrote each line
aontu template handler.ts # the template's meaning
aontu trace handler.ts # and it answers the same