Packages

A pure-Elixir implementation of the Open vSwitch Database Management Protocol (OVSDB), per RFC 7047. Provides protocol primitives, operation and transaction builders, schema parsing, client/server session handling over TCP/TLS, and an in-memory IDL replica.

Current section

Files

Jump to
ovsdb_ex lib ovsdb protocol.ex
Raw

lib/ovsdb/protocol.ex

defmodule OVSDB.Protocol do
@moduledoc """
JSON-RPC 1.0 envelope handling for OVSDB (RFC 7047 §4).
This module has one job: build, classify, serialize, and parse the
three kinds of JSON-RPC 1.0 messages that OVSDB uses. It does NOT
know about:
* The meaning of any specific method (`list_dbs`, `transact`, etc.)
— that's `OVSDB.Operation` and the method-specific
builders in Layer 3.
* OVSDB value encoding (UUID wire form, set tagging, etc.) — the
caller is expected to pass JSON-ready terms in `params` and
`result`. Use `OVSDB.Value.encode/1` to prepare them.
* Socket IO or message framing — that's `OVSDB.Transport`.
* Request/response correlation or session state — that's
`OVSDB.Session`.
## Message shapes (RFC 7047 §4 + JSON-RPC 1.0)
**Request** — client invokes a method on the server:
%{"method" => "list_dbs", "params" => [], "id" => 42}
**Response** — server replies to a request. Exactly one of `result`
and `error` is non-null:
%{"result" => [...], "error" => nil, "id" => 42}
%{"result" => nil, "error" => "unknown database", "id" => 42}
**Notification** — either side sends a one-way message. `id` is
explicitly null, which is how notifications are distinguished from
requests:
%{"method" => "update", "params" => [...], "id" => nil}
## Design philosophy
All the building and classification functions work on plain Elixir
maps. There is no OVSDB-specific struct involved; the wire form
maps directly to Elixir maps with string keys, and that's what
flows through the whole library. This makes it easy to inspect,
test, and match on messages without import ceremony.
Jason is touched only in `serialize/1` and `parse/1`. Everything
else is pure Elixir term manipulation.
"""
@type id :: non_neg_integer() | String.t()
@type request :: %{
required(String.t()) => term(),
optional(String.t()) => term()
}
@type response :: %{
required(String.t()) => term()
}
@type notification :: %{
required(String.t()) => term()
}
@type message :: request() | response() | notification()
@type classified ::
{:request, %{id: id(), method: String.t(), params: list()}}
| {:response, %{id: id(), result: term() | nil, error: term() | nil}}
| {:notification, %{method: String.t(), params: list()}}
@typedoc """
Errors that classification and parsing may return. The set is
closed — new error atoms will be added via a minor or major
version bump.
"""
@type classify_error ::
:unclassifiable
| :response_id_null
| :response_result_and_error_both_null
| :response_result_and_error_both_set
| {:not_a_map, term()}
| {:missing_fields, [String.t()]}
| {:malformed_method_message, map()}
@typedoc """
Errors that `parse/1` may return, beyond those `classify_error`
covers when composed via `parse_and_classify/1`.
"""
@type parse_error ::
:not_an_object
| {:parse_error, Jason.DecodeError.t()}
# RFC 7047 §4.1 — the only methods OVSDB defines.
@client_methods ~w(list_dbs get_schema transact cancel monitor monitor_cancel
lock steal unlock echo)
@server_notifications ~w(update locked stolen echo)
@methods @client_methods ++ @server_notifications
@doc """
Returns the list of all method names defined by RFC 7047.
"""
@spec known_methods() :: [String.t()]
def known_methods, do: Enum.uniq(@methods)
@doc """
Returns `true` if `method` is a method name defined by RFC 7047.
iex> OVSDB.Protocol.known_method?("transact")
true
iex> OVSDB.Protocol.known_method?("something_else")
false
"""
@spec known_method?(String.t()) :: boolean()
def known_method?(method) when is_binary(method), do: method in @methods
def known_method?(_), do: false
# ---------------------------------------------------------------------------
# Builders
# ---------------------------------------------------------------------------
@doc """
Builds a request message.
`id` must be non-null (a request with null id is a notification).
`params` must be a list (may be empty). `method` should be one of
the RFC 7047 methods but is not enforced — this function accepts
any string, since OVSDB implementations sometimes add vendor
extensions.
iex> OVSDB.Protocol.request("list_dbs", [], 1)
%{"method" => "list_dbs", "params" => [], "id" => 1}
iex> OVSDB.Protocol.request("get_schema", ["Open_vSwitch"], "my-id")
%{"method" => "get_schema", "params" => ["Open_vSwitch"], "id" => "my-id"}
"""
@spec request(String.t(), list(), id()) :: request()
def request(method, params, id)
when is_binary(method) and is_list(params) and not is_nil(id) do
%{"method" => method, "params" => params, "id" => id}
end
@doc """
Builds a successful response message.
iex> OVSDB.Protocol.response(1, ["Open_vSwitch"])
%{"result" => ["Open_vSwitch"], "error" => nil, "id" => 1}
"""
@spec response(id(), term()) :: response()
def response(id, result) when not is_nil(id) do
%{"result" => result, "error" => nil, "id" => id}
end
@doc """
Builds an error response message.
The `error` string should be a short OVSDB-defined error code
(e.g. `"unknown database"`, `"canceled"`). An optional `details`
string may carry a longer human-readable explanation, per
RFC 7047 §3.1's `<e>` grammar.
iex> OVSDB.Protocol.error_response(1, "unknown database")
%{"result" => nil, "error" => "unknown database", "id" => 1}
iex> OVSDB.Protocol.error_response(1, "syntax error", "unexpected token")
%{
"result" => nil,
"error" => %{"error" => "syntax error", "details" => "unexpected token"},
"id" => 1
}
"""
@spec error_response(id(), String.t(), String.t() | nil) :: response()
def error_response(id, error, details \\ nil) when not is_nil(id) and is_binary(error) do
error_value =
case details do
nil -> error
d when is_binary(d) -> %{"error" => error, "details" => d}
end
%{"result" => nil, "error" => error_value, "id" => id}
end
@doc """
Builds a notification message (one-way, no response expected).
iex> OVSDB.Protocol.notification("echo", ["keepalive"])
%{"method" => "echo", "params" => ["keepalive"], "id" => nil}
iex> OVSDB.Protocol.notification("update", ["my_monitor", %{}])
%{"method" => "update", "params" => ["my_monitor", %{}], "id" => nil}
"""
@spec notification(String.t(), list()) :: notification()
def notification(method, params) when is_binary(method) and is_list(params) do
%{"method" => method, "params" => params, "id" => nil}
end
# ---------------------------------------------------------------------------
# Classification
# ---------------------------------------------------------------------------
@doc """
Classifies a parsed message map into its canonical form.
Returns `{:request, ...}`, `{:response, ...}`, or
`{:notification, ...}` with the keys extracted into atom-keyed
struct-ish maps for convenient pattern matching. Returns
`{:error, reason}` for malformed messages.
## Classification rules (RFC 7047 §4 + JSON-RPC 1.0)
* If `method` is present AND `id` is non-null → **request**
* If `method` is present AND `id` is null → **notification**
* If `method` is absent (i.e. `result`/`error` present) → **response**
The server MUST send exactly one of `result` or `error` as null in
a response; this function validates that invariant.
iex> OVSDB.Protocol.classify(%{"method" => "list_dbs", "params" => [], "id" => 1})
{:ok, {:request, %{id: 1, method: "list_dbs", params: []}}}
iex> OVSDB.Protocol.classify(%{"method" => "update", "params" => [], "id" => nil})
{:ok, {:notification, %{method: "update", params: []}}}
iex> OVSDB.Protocol.classify(%{"result" => [], "error" => nil, "id" => 1})
{:ok, {:response, %{id: 1, result: [], error: nil}}}
iex> OVSDB.Protocol.classify(%{"method" => "x"})
{:error, {:missing_fields, ["params", "id"]}}
"""
@spec classify(term()) :: {:ok, classified()} | {:error, classify_error()}
def classify(message) when is_map(message) do
case {
Elixir.Map.has_key?(message, "method"),
Elixir.Map.has_key?(message, "params"),
Elixir.Map.has_key?(message, "id"),
Elixir.Map.has_key?(message, "result"),
Elixir.Map.has_key?(message, "error")
} do
{true, true, true, _, _} ->
classify_method_bearing(message)
{true, _, _, _, _} ->
missing =
[
{"params", Elixir.Map.has_key?(message, "params")},
{"id", Elixir.Map.has_key?(message, "id")}
]
|> Enum.reject(fn {_, present} -> present end)
|> Enum.map(fn {name, _} -> name end)
{:error, {:missing_fields, missing}}
{false, _, true, true, true} ->
classify_response(message)
{false, _, _, _, _} ->
{:error, :unclassifiable}
end
end
def classify(other), do: {:error, {:not_a_map, other}}
defp classify_method_bearing(%{"method" => method, "params" => params, "id" => id})
when is_binary(method) and is_list(params) do
case id do
nil -> {:ok, {:notification, %{method: method, params: params}}}
_ -> {:ok, {:request, %{id: id, method: method, params: params}}}
end
end
defp classify_method_bearing(msg) do
{:error, {:malformed_method_message, msg}}
end
defp classify_response(%{"id" => id, "result" => result, "error" => error}) do
cond do
is_nil(id) ->
{:error, :response_id_null}
is_nil(result) and is_nil(error) ->
{:error, :response_result_and_error_both_null}
not is_nil(result) and not is_nil(error) ->
{:error, :response_result_and_error_both_set}
true ->
{:ok, {:response, %{id: id, result: result, error: error}}}
end
end
# ---------------------------------------------------------------------------
# Wire I/O
# ---------------------------------------------------------------------------
@doc """
Serializes a message map to iodata suitable for writing to the wire.
Does not add any framing — OVSDB sends bare JSON objects
back-to-back on the stream with no length prefix or delimiter.
Message boundaries are found by the receiver via JSON
brace-matching.
iex> req = OVSDB.Protocol.request("list_dbs", [], 1)
iex> iodata = OVSDB.Protocol.serialize(req)
iex> IO.iodata_to_binary(iodata)
"{\\"id\\":1,\\"method\\":\\"list_dbs\\",\\"params\\":[]}"
"""
@spec serialize(message()) :: iodata()
def serialize(message) when is_map(message) do
Jason.encode_to_iodata!(message)
end
@doc """
Parses one complete JSON object from a binary into a message map.
The caller is responsible for message framing — this function
expects exactly one JSON object and does not tolerate trailing
bytes. Returns `{:ok, map}` on success, `{:error, reason}` on
parse failure or if the parsed value is not a JSON object.
iex> {:ok, msg} = OVSDB.Protocol.parse(~s({"method":"list_dbs","params":[],"id":1}))
iex> msg
%{"method" => "list_dbs", "params" => [], "id" => 1}
iex> {:error, {:parse_error, _reason}} = OVSDB.Protocol.parse("not json")
iex> :ok
:ok
iex> OVSDB.Protocol.parse("[1,2,3]")
{:error, :not_an_object}
"""
@spec parse(binary()) :: {:ok, message()} | {:error, parse_error()}
def parse(binary) when is_binary(binary) do
case Jason.decode(binary) do
{:ok, map} when is_map(map) -> {:ok, map}
{:ok, _non_map} -> {:error, :not_an_object}
{:error, reason} -> {:error, {:parse_error, reason}}
end
end
@doc """
Convenience: parse a binary then classify it in one step.
Returns the same `{:ok, classified()}` / `{:error, term()}` shape
as `classify/1`, with parse errors subsumed.
"""
@spec parse_and_classify(binary()) ::
{:ok, classified()} | {:error, parse_error() | classify_error()}
def parse_and_classify(binary) when is_binary(binary) do
with {:ok, map} <- parse(binary) do
classify(map)
end
end
end