Index

A generic REST interface into Elixir

Jan 15, 2026

This is a proof of concept implementation of the idea of a generic REST interface to Elixir's GenServer call and cast functions. Furthermore we add variants to receive messages as server-sent events.

The Exploring distributed Elixir post can be seen as a precursor to this post. The demo key-value and pub-sub servers were introduced there.

Here is an example of the generic REST interface, accessing a simple key-value GenServer. First in Elixir using GenServer.call/2 and a :global registered name.

iex> GenServer.call({:global, :kv1}, {:set, :foo, 123})
true
iex> GenServer.call({:global, :kv1}, {:get, :foo})
123

Now using the REST interface.

$ curl http://localhost:4000/process/kv1/call -d '["set", "foo", 123]'
true
$ curl http://localhost:4000/process/kv1/call -d '["get", "foo"]'
123

And the same expressed as an Elixir unit test using Req, a powerful Elixir HTTP client.

test "key-value genserver" do
assert(Req.post!("http://localhost:4000/process/kv1/call", json: ["set", "foo", 123]).body)
assert(Req.post!("http://localhost:4000/process/kv1/call", json: ["get", "foo"]).body == 123)
end

The resource we talk to is a process, identified by a global name. To make a call, you POST a JSON message to the call sub-resource. The result is converted back to JSON and returned. The URI pattern is:

POST /process/:process_name/call json-message => json-result

The concession we must make is that our message patterns and results in Elixir should be compatible with JSON - so no tuples. It is easy enough to define aliases for handle_call and to be careful with results. Another option would be to translate results in the alias definitions.

The first Elixir snippet can thus also be rewritten as follows, using lists as message patterns.

iex> GenServer.call({:global, :kv1}, [:set, :foo, 123])
true
iex> GenServer.call({:global, :kv1}, [:get, :foo])
123

It was after reading Allan MacGregor's excellent Build an Elixir App with Cowboy article that I got the inspiration to try an implementation of the idea described above. Make sure to read this article if you never heard about Cowboy, one of Erlang's HTTP server implementations.

The source code for this and other experiments can be found in the distributed_elixir_exploration repository.

Remember, this a proof of concept implementation exploring an idea, it lacks error handling and security, while focusing on simplicity.

In our implementation we follow the ideas from the article mentioned earlier. The first step is the definition of an HTTP GenServer where everything happens in the init function.

defmodule Gsweb.HTTPServer do
use GenServer
require Logger
@moduledoc """
A REST interface to GenServer call and cast
with variants to receive incoming messages
and broadcast them as server-sent events
"""
@one_day_ms 24 * 60 * 60 * 1_000
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def init(_opts) do
dispatch =
:cowboy_router.compile([
{:_,
[
{"/up", Gsweb.Up, []},
{"/process/:process_name/call", Gsweb.Call, []},
{"/process/:process_name/call-receive", Gsweb.CallReceive, []},
{"/process/:process_name/cast", Gsweb.Call, []},
{"/process/:process_name/cast-receive", Gsweb.CallReceive, []}
]}
])
{:ok, _} =
:cowboy.start_clear(
:http,
[port: 4000],
%{
idle_timeout: @one_day_ms,
env: %{dispatch: dispatch}
}
)
Logger.notice("Cowboy ready at http://localhost:4000")
{:ok, %{}}
end
end

A dispatcher is compiled from a specification where routes are mapped to handler modules. Then the server is started with various options, including the dispatcher.

The minimal handler is the one for /up, the module Up. It returns a 200 OK response. Handlers are special behaviours, all work being done in the init function.

defmodule Gsweb.Up do
@behaviour :cowboy_handler
def init(req, state) do
new_req = :cowboy_req.reply(200, %{"content-type" => "text/plain"}, "OK\r\n", req)
{:ok, new_req, state}
end
end

We can now look at the implementation of Call, which follows a similar pattern. We need to get the process name from the matched URL, as well as the request body, which we parse as JSON.

We can now execute the GenServer call itself. We use a little helper function to resolve the process name, smoothing out the difference between strings and atoms in the :global registry.

Finally we reply with the JSON encoded result.

defmodule Gsweb.Call do
@behaviour :cowboy_handler
import Gsweb.Utils
require Logger
def init(req, state) do
process_name = :cowboy_req.binding(:process_name, req)
{:ok, body, req} = :cowboy_req.read_body(req)
message = JSON.decode!(body)
Logger.debug("/process/#{process_name}/call #{inspect(message)}")
result = GenServer.call(resolve(process_name), message)
req =
:cowboy_req.reply(
200,
%{"content-type" => "application/json"},
JSON.encode!(result) <> "\r\n",
req
)
{:ok, req, state}
end
end

If anything goes wrong, the handling of the request will fail, and return a 500 Internal server error. With a bit more work, we could return more semantic errors at the HTTP level, such as a 404 Not found when the process does not exist.

The Cast handler is almost identifical, but the semantics are different. GenServer cast does not wait for a reply, it is similar to the fundamental Process send. We return a JSON true value immediately.

With the generic call interface, any HTTP client in any language with JSON support can interact with any Elixir GenServer whose global name we expose. I think that is already pretty cool and powerful.

But we are still missing something, namely the ability to receive messages, pushed asynchronously. Doing this in general would be too hard, but we can do something almost as good that turns out to be quite elegant as well.

In a pub-sub server, like in the very simple one we are using as a demo, the caller subscribes by registering itself (its process) that will subsequently receive broadcasted messages. A similar concept is used in other places, like when one process monitors another one.

When Cowboy handles a request, it creates a new process to handle it. If we first do a call or cast to register the handling process somewhere, and then go into a loop where we receive messages, we can push them to the HTTP client via server-sent events (specification). We remain in this loop forever, until the HTTP client close, upon which the handling process dies and is unsubscribed automatically.

In Elixir, this would look as follows. First node foo subscribes to topic1.

iex(foo@pathfinder)> GenServer.call({:global, :ps1}, {:subscribe, :topic1})
true
iex(foo@pathfinder)> self
#PID<0.110.0>
iex(foo@pathfinder)> GenServer.call({:global, :ps1}, {:subscribers, :topic1})
[#PID<0.110.0>]

Then node bar broadcasts a message on topic1.

iex(bar@pathfinder)> GenServer.call({:global, :ps1}, {:broadcast, :topic1, {:msg, :yo}})
true

Which is then received on foo.

iex(foo@pathfinder)> flush
{:msg, :yo}
:ok

In our demo pub-sub server, the process doing the broadcast does not receive the message it is broadcasting.

Using the REST interface to do the same looks as follows. First we subscribe and enter a loop listening for server-sent events. Initially there is not output.

$ curl -N http://localhost:4000/process/ps1/call-receive -d '["subscribe", "topic1"]'

Then, via another terminal session, we broadcast a message.

$ curl http://localhost:4000/process/ps1/call -d '["broadcast", "topic1", ["msg", "hi"]]'
true

Which is then received in the first terminal session.

$ curl -N http://localhost:4000/process/ps1/call-receive -d '["subscribe", "topic1"]'
data: ["msg","hi"]

We can write the same as an Elixir test, though we need a little hacking to make it work.

test "pub-sub genserver" do
Process.spawn(
fn ->
Process.sleep(100)
assert(
Req.post!("http://localhost:4000/process/ps1/call",
json: ["broadcast", "topic1", ["msg", 101]]
).body
)
end,
[]
)
Req.post!(
"http://localhost:4000/process/ps1/call-receive",
json: ["subscribe", "topic1"],
into: fn {:data, data}, {req, resp} ->
Logger.debug("received: #{data}")
if String.starts_with?(data, "data:") do
assert(String.slice(data, 6..-1//1) |> JSON.decode!() == ["msg", 101])
{:halt, {req, resp}}
else
{:cont, {req, resp}}
end
end
)
end

The URI pattern changes slightly, now using the call-receive instead of the call sub-resource.

POST /process/:process_name/call-receive json-message => json-result

Here is the implementation of the CallReceive handler.

defmodule Gsweb.CallReceive do
@behaviour :cowboy_handler
import Gsweb.Utils
require Logger
def init(req, state) do
process_name = :cowboy_req.binding(:process_name, req)
{:ok, body, req} = :cowboy_req.read_body(req)
message = JSON.decode!(body)
Logger.debug("/process/#{process_name}/call-receive #{inspect(message)}")
_result = GenServer.call(resolve(process_name), message)
req =
:cowboy_req.stream_reply(
200,
%{
"content-type" => "text/event-stream",
"cache-control" => "no-cache",
"connection" => "keep-alive"
},
req
)
loop(
req,
fn message ->
json_message = JSON.encode!(message)
Logger.debug(json_message)
frame = "data: #{json_message}\n\n"
:ok = :cowboy_req.stream_body(frame, :nofin, req)
end
)
{:ok, req, state}
end
end

This is similir to the Call handler. This time the result in ignored, regardless if it is a call or cast, and we return a special streaming HTTP response of content-type text/event-stream. We enter a loop, streaming any incoming message as JSON back to the HTTP client, formatted according to the server-sent events syntax. We could use the event type option, but we did not to keep things simple.

The loop implementation includes a heartbeat to keep the connection open.

@heartbeat_ms 60_000
def loop(req, handler) do
receive do
{:cowboy_req, :terminate} ->
:ok
message ->
handler.(message)
loop(req, handler)
after
@heartbeat_ms ->
handler.(["heartbeat", to_string(DateTime.utc_now())])
loop(req, handler)
end
end

With this final extension to the generic call interface, any HTTP client in any language with JSON and response streaming support can fully interact with any Elixir GenServer whose global name we expose, sending synchronous calls, asynchronous casts and receiving pushed messages.