An Erlang HTTP service, from Gradle¶
This example is the smallest honest version of the embedding shape people ask
for: a Java process serves HTTP, and the service itself is Erlang. The Java class
is fixed plumbing you never edit; the routes, status codes and bodies live in
.erl files next to it.
The runtime is not built from this repository. It is the published artifact:
plugins {
id 'application'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'io.github.slayyyyer:jerlang:3.0.0'
}
So this is also a check that the Central artifact is usable on its own, without a checkout of the source tree.
The shape of it¶
examples/http-service/
├── build.gradle
├── settings.gradle
├── src/main/java/example/Server.java fixed plumbing
└── src/main/erl/
├── api.erl routing, status codes, JSON
└── store.erl the state
One request crosses the boundary as three binaries and comes back as one tuple:
Server.java reads the HttpServer exchange, calls api:handle/3 with those
three terms, and writes whatever tuple comes back:
Term reply = node.apply("api", "handle",
Bridge.toTerm(exchange.getRequestMethod().getBytes(StandardCharsets.UTF_8)),
Bridge.toTerm(path.getBytes(StandardCharsets.UTF_8)),
Bridge.toTerm(body));
Object[] parts = (Object[]) Bridge.toJava(reply);
send(exchange, ((Number) parts[0]).intValue(),
new String((byte[]) parts[1], StandardCharsets.UTF_8), (byte[]) parts[2]);
That is the whole Java side of the contract. 204 and 304 send no body; the
listener stops accepting before node.close() drains on shutdown.
Running it¶
Requires a JDK 21 and Gradle 8.5 or newer — tested with 9.8.2. No Gradle wrapper
is committed, so use an installed Gradle. JErlang compiles the service's .erl
files, so no erl or erlc is needed.
The run task passes jerlang.example.ebin; the installed script falls back to
build/ebin relative to the working directory. compileErlang runs JErlang's
compiler once, and classes depends on it — nothing compiles Erlang at run time,
and the service needs no Erlang installation to build or run.
The HTTP thread pool and the scheduler count are the same number on purpose. One HTTP thread is inside one Erlang call at a time, so more HTTP threads than schedulers would only queue work that is already runnable.
Every route is Erlang¶
api.erl is a set of handle/3 clauses — method as the first argument, path as
the second:
handle(<<"GET">>, <<"/health">>, _Body) ->
text(200, <<"ok\n">>);
handle(<<"GET">>, <<"/kv">>, _Body) ->
json(200, kv_json(store:all()));
handle(<<"GET">>, Path, _Body) ->
case key_of(Path) of
{ok, Key} -> lookup(Key);
error -> json(404, error_json(<<"no_such_route">>, Path))
end;
handle(Method, _Path, _Body) ->
json(405, error_json(<<"method_not_allowed">>, Method)).
Adding a route is adding a clause above the catch-alls. Nothing in Java changes, and no route is compiled into the listener.
key_of/1 matches the path as a binary, because this runtime has no
binary:split/2 and no string module:
A key is one path segment: nothing is URL-decoded, so a key cannot contain a slash. That is the limit of an example, not of the runtime.
The state is a process¶
store.erl keeps the entries in one registered process with a receive loop:
loop(Entries) ->
receive
{From, {get, Key}} ->
From ! {store, find(Key, Entries)},
loop(Entries);
{From, {put, Key, Value}} ->
From ! {store, ok},
loop(store(Key, Value, Entries))
end.
Callers send a message and wait in a receive. That is a parked process, not a
blocked thread: a thousand concurrent callers cost a thousand processes and no
OS threads, and the scheduler keeps running everything else while they wait.
It is a list rather than a map for a concrete reason — see what this runtime does not have below.
A session¶
$ curl -s localhost:8080/health
ok
$ printf 'value with "quotes" and\na newline' | curl -s -X PUT --data-binary @- localhost:8080/kv/name -o /dev/null -w '%{http_code}\n'
204
$ curl -s localhost:8080/kv/name
{"key":"name","value":"value with \"quotes\" and\na newline"}
$ curl -s -X PUT --data-binary 'Berlin' -o /dev/null -w '%{http_code}\n' localhost:8080/kv/city
204
$ curl -s localhost:8080/kv
[{"key":"city","value":"Berlin"},{"key":"name","value":"value with \"quotes\" and\na newline"}]
$ curl -s localhost:8080/kv/missing
{"error":"not_found","detail":"missing"}
$ curl -s -X DELETE localhost:8080/kv/city -o /dev/null -w '%{http_code}\n'
204
$ curl -s -X DELETE localhost:8080/kv/city -o /dev/null -w '%{http_code}\n'
404
$ curl -s -X POST localhost:8080/kv/name
{"error":"method_not_allowed","detail":"POST"}
Timings on a loopback connection, from curl's time_total: GET /health
0.0019 s, PUT /kv/a 0.0020 s, GET /kv/a 0.0027 s, with the first request after
startup at 0.0077 s. That is a single client measuring round trips — not a
throughput benchmark; for those, see Benchmarks.
Metrics come from the runtime, not from Erlang¶
GET /metrics is served in Java, straight from the node's own counters:
if (path.equals("/metrics")) {
send(exchange, 200, "text/plain; charset=utf-8",
node.metrics().toText().getBytes(StandardCharsets.UTF_8));
return;
}
$ curl -s localhost:8080/metrics | head -6
jerlang_processes 1
jerlang_ports 0
jerlang_run_queue 0
jerlang_reductions 423
jerlang_mailbox_messages 0
jerlang_messages_delivered 22
Scraping keeps working when the thing that broke is the service's own Erlang
code. The request path has the other half of that property: when api:handle
raised during development, those requests answered 500 and the listener kept
serving.
What this runtime does not have yet¶
Three gaps shaped this code, all checked against 1.0.0 by calling them:
| What | Result |
|---|---|
binary:split/2, binary:replace/3, binary:part/3 |
Invalid function: binary:split/2 |
the string module |
Invalid module: string |
iolist_to_binary/1 |
Invalid function: erlang:iolist_to_binary/1 |
list_to_binary/1 on a computed list |
Invalid function: erlang:list_to_binary/1 — it only works where erlc folds the call at compile time |
<<Acc/binary, More/binary>> inside a loop |
compiles, then fails at run time: Unsupported bs_create_bin segment type: private_append |
| map lookups with binary keys | compare by object identity, not content |
The last one is why store.erl is a list. Storing a key and then looking it up
with an equal but separately built binary returns error:
maps:find(<<"a">>, maps:put(binary:copy(<<"a">>), 1, #{})) % error
K = <<"a">>, maps:find(<<"a">>, maps:put(K, 1, #{})) % error
K = <<"a">>, maps:find(K, maps:put(K, 1, #{})) % {ok,1}
Pattern matching and =:= do compare binaries by content, so the store matches
keys with a head pattern instead. The same reason is why escape/1 recurses and
appends the result of the recursive call rather than threading an accumulator.
Check BEAM compatibility before reaching for a function from the standard library; these six were found by trying them, not by reading a list.
If you ship something like this¶
- Move the state to ETS (measured at 5x BEAM's cost) or to an OTP
gen_serverfor supervision, and only then worry about the linear scan. - Decode URLs and handle query strings; the route matcher here takes one segment.
- Use a JSON library on one side of the boundary rather than hand-rolling the encoder, or keep the bodies to a shape you control.
- Terminate TLS in front of it and keep the listener on loopback, as with any plain-HTTP service. See Operating a node for the drain timeout and shutdown behaviour.