Draft Elixir Woody RPC library (#1)

This commit is contained in:
Andrew Mayorov 2022-09-30 19:21:13 +03:00 committed by GitHub
parent f95903fcec
commit 783cfd0eb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 920 additions and 0 deletions

4
.formatter.exs Normal file
View File

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
woody_ex-*.tar
# Temporary files, for example, from tests.
/tmp/
# Generated test files
/test/gen

View File

@ -0,0 +1,90 @@
defmodule Woody.Client.Builder do
@moduledoc """
This module provides macro facilities to generate correctly typespecced clients for some [Thrift
service](`Woody.Thrift.service()`).
You could just `use` it in a module of your choice.
```
defmodule MyClient do
use Woody.Client.Builder, service: {:woody_test_thrift, :Weapons}
end
defmodule MyLogic do
def rotate_weapon(client, name) do
shovel = MyClient.get_weapon(client, "shovel", "...")
double_sided_shovel = MyClient.switch_weapon(shovel, :next, 1, "...)
end
end
```
"""
alias Woody.Client.Http, as: Client
alias Woody.Thrift
@spec __using__(Keyword.t) :: Macro.output
defmacro __using__(options) do
service = Keyword.fetch!(options, :service)
for function <- Thrift.get_service_functions(service) do
def_name = underscore(function)
variable_names = gen_variable_names(service, function, __MODULE__)
variable_types = gen_variable_types(service, function)
return_type = {:ok, gen_return_type(service, function)}
exception_types = gen_exception_types(service, function)
result_type = if Enum.empty?(exception_types) do
return_type
else
Enum.reduce(exception_types, return_type, fn type, acc ->
{:|, [], [{:exception, type}, acc]}
end)
end
quote location: :keep do
@spec unquote(def_name) (Client.t, unquote_splicing(variable_types)) :: unquote(result_type)
def unquote(def_name) (client, unquote_splicing(variable_names)) do
Client.call(
client,
unquote(service),
unquote(function),
{unquote_splicing(variable_names)}
)
end
end
end
end
defp gen_variable_names(service, function, context) do
for field <- Thrift.get_function_params(service, function) do
field
|> Thrift.get_field_name()
|> underscore()
|> Macro.var(context)
end
end
defp gen_variable_types(service, function) do
for field <- Thrift.get_function_params(service, function) do
field
|> Thrift.get_field_type()
|> Thrift.MacroHelpers.map_type()
end
end
defp gen_exception_types(service, function) do
for type <- Thrift.get_function_exceptions(service, function) do
Thrift.MacroHelpers.map_type(type)
end
end
defp gen_return_type(service, function) do
Thrift.get_function_reply(service, function)
|> Thrift.MacroHelpers.map_type()
end
@spec underscore(atom) :: atom
defp underscore(atom) do
atom |> to_string() |> Macro.underscore() |> String.to_atom()
end
end

56
lib/woody/client/http.ex Normal file
View File

@ -0,0 +1,56 @@
defmodule Woody.Client.Http do
@moduledoc """
A Woody RPC client over HTTP/1.1 transport protocol.
"""
alias Woody.Thrift
alias :woody_client, as: WoodyClient
@enforce_keys [:ctx]
defstruct ctx: nil, opts: %{}
@type t :: %__MODULE__{ctx: Woody.Context.t, opts: WoodyClient.options}
@type url :: String.t
@type args :: tuple
@doc """
Creates a fresh Woody client given [context](`Woody.Context`) and URL where the server could be
reached.
"""
@spec new(Woody.Context.t, url, Keyword.t) :: t
def new(ctx, url, options \\ []) do
opts = %{
protocol: :thrift,
transport: :http,
event_handler: [],
url: url
}
opts = options |> Enum.reduce(opts, fn
{:event_handler, evh}, opts -> %{opts | event_handler: List.wrap(evh)}
{:transport, transport_opts}, opts -> %{opts | transport_opts: transport_opts}
{:resolver, resolver_opts}, opts -> %{opts | resolver_opts: resolver_opts}
end)
%__MODULE__{
ctx: ctx,
opts: opts
}
end
@spec call(t, Thrift.service, Thrift.tfunction, args) :: {:ok, any} | {:exception, any}
def call(%__MODULE__{} = client, service, function, args) do
request = {service, function, args}
try do
WoodyClient.call(request, client.opts, client.ctx)
catch
:error, {:woody_error, {source, :result_unexpected, details}} ->
raise Woody.UnexpectedError, source: source, details: details
:error, {:woody_error, {source, class, details}} when class in [
:resource_unavailable,
:result_unknown
] ->
raise Woody.BadResultError, source: source, class: class, details: details
end
end
end

49
lib/woody/context.ex Normal file
View File

@ -0,0 +1,49 @@
defmodule Woody.Context do
@moduledoc """
Context holds few important bits information for a single RPC.
1. A triple of identifiers required to identify a single request in a system and transitively
correlate it to all other requests issued from a root request. This is called RPC ID and consists
of _trace id_, _span id_ and _parent id_.
2. A _ which marks the latest moment of time the caller expects RPC to be handled. If deadline is
already in the past there's no point to handle it at all.
"""
alias :woody_context, as: WoodyContext
@type t :: WoodyContext.ctx
@doc """
Creates new root context with automatically generated unique RPC ID.
"""
@spec new() :: t
def new do
WoodyContext.new()
end
@spec new(keyword) :: t
def new(opts) do
rpc_id = Keyword.get(opts, :rpc_id) || new_rpc_id(Keyword.get(opts, :trace_id))
meta = Keyword.get(opts, :meta) || :undefined
deadline = Keyword.get(opts, :deadline) || :undefined
WoodyContext.new(rpc_id, meta, deadline)
end
defp new_rpc_id(trace_id) when is_binary(trace_id) do
WoodyContext.new_rpc_id("undefined", trace_id, WoodyContext.new_req_id())
end
defp new_rpc_id(nil) do
WoodyContext.new_req_id() |> WoodyContext.new_rpc_id()
end
@doc """
Creates a child context which inherits `ctx`'s _trace id_ and takes `ctx`'s _span id_ as
_parent id_.
"""
@spec child(t) :: t
def child(ctx) do
WoodyContext.new_child(ctx)
end
end

80
lib/woody/errors.ex Normal file
View File

@ -0,0 +1,80 @@
defmodule Woody.UnexpectedError do
@moduledoc """
This error tells the client that handling an RPC ended unexpectedly, usually as a result of some
logic error.
"""
@typedoc """
Source of the error.
* _internal_ means that this service instance failed to handle an RPC,
* _external_ means that some external system failed to do it.
"""
@type source :: :internal | :external
@type t :: %__MODULE__{
source: source,
details: String.t
}
defexception [:source, :details]
@impl true
@spec message(t) :: String.t
def message(ex) do
verb = case ex.source do
:internal -> "got"
:external -> "received"
end
"#{verb} an unexpected error: #{ex.details}"
end
end
defmodule Woody.BadResultError do
@moduledoc """
This error tells the client that the result of RPC is unknown, meaning that the client should
now deal with uncertainty in the system, by retrying it for example.
"""
@typedoc """
Source of the error.
* `:internal` means that this service instance failed to handle an RPC.
* `:external` means that some external system failed to do it.
"""
@type source :: :internal | :external
@typedoc """
Uncertainty class of the error.
* `:resource_unavailable` means that the system didn't even attempt to handle an RPC, which in
turn means that state of the system _definitely_ hasn't changed. This usually happens when the
server is unreachable or the deadline is already in the past.
* `:result_unknown` means that RPC has _probably_ reached the server but it didn't respond, the
system now in the state of uncertainty: the client do not know for sure if RPC has been handled
or not. This usually happens when the server goes offline or the deadline is reached before
getting a response.
"""
@type class :: :resource_unavailable | :result_unknown
@type t :: %__MODULE__{
source: source,
class: class,
details: String.t
}
defexception [:source, :class, :details]
@impl true
@spec message(t) :: String.t
def message(ex) do
verb = case ex.source do
:internal -> "got"
:external -> "received"
end
summary = case ex.class do
:resource_unavailable -> "resource unavailable"
:result_unknown -> "result is unknown"
end
"#{verb} no result, #{summary}: #{ex.details}"
end
end

116
lib/woody/server/builder.ex Normal file
View File

@ -0,0 +1,116 @@
defmodule Woody.Server.Builder do
@moduledoc """
This module provides macro facilities to automatically generate handler behaviours and handler
boilerplate for some [Thrift service](`Woody.Thrift.service()`).
"""
alias Woody.Server.Builder
alias Woody.Server.Http.Handler
alias Woody.Thrift
@spec defservice(module, Woody.Thrift.service) :: Macro.output
defmacro defservice(modname, service) do
callbacks = for function <- Thrift.get_service_functions(service) do
def_name = gen_function_name(function)
var_types = gen_variable_types(service, function)
return_type = gen_return_type(service, function)
quote do
@callback unquote(def_name) (unquote_splicing(var_types), Woody.Context.t, Handler.hdlopts) ::
unquote(return_type) | Handler.throws(any)
end
end
macro = {:quote, [context: Builder], [
[do: quote do
require Builder
Builder.__impl_service__(unquote(modname), unquote(service))
end]
]}
quote location: :keep do
defmodule unquote(modname) do
defmacro __using__(options \\ []) do
unquote(macro)
end
unquote_splicing(callbacks)
end
end
end
defmacro __impl_service__(modname, service) do
functions = Thrift.get_service_functions(service)
handler = for function <- functions do
def_name = gen_function_name(function)
var_names = gen_variable_names(service, function, __MODULE__)
quote do
defp __handle__(unquote(function), {unquote_splicing(var_names)}, ctx, hdlopts) do
unquote(def_name)(unquote_splicing(var_names), ctx, hdlopts)
end
end
end
quote location: :keep do
@behaviour unquote(modname)
@behaviour Woody.Server.Http.Handler
@impl Woody.Server.Http.Handler
@spec service() :: Woody.Thrift.service
def service, do: unquote(service)
@impl Woody.Server.Http.Handler
def handle_function(function_name, args, context, hdlopts) do
__handle__(function_name, args, context, hdlopts)
end
unquote_splicing(handler)
end
end
defp gen_variable_names(service, function, context) do
for field <- Thrift.get_function_params(service, function) do
field
|> Thrift.get_field_name()
|> underscore()
|> Macro.var(context)
end
end
defp gen_variable_types(service, function) do
for field <- Thrift.get_function_params(service, function) do
field
|> Thrift.get_field_type()
|> Thrift.MacroHelpers.map_type()
end
end
defp gen_return_type(service, function) do
Thrift.get_function_reply(service, function)
|> Thrift.MacroHelpers.map_type()
end
@spec gen_function_name(atom) :: atom
defp gen_function_name(function) do
idiomatic_name = function |> Atom.to_string() |> Macro.underscore()
String.to_atom("handle_#{idiomatic_name}")
end
@spec underscore(atom) :: atom
defp underscore(atom) do
atom |> Atom.to_string() |> Macro.underscore() |> String.to_atom()
end
end

127
lib/woody/server/http.ex Normal file
View File

@ -0,0 +1,127 @@
defmodule Woody.Server.Http do
@moduledoc """
A Woody RPC HTTP/1.1 transport protocol server.
"""
alias :woody_server_thrift_v2, as: WoodyServer
alias :woody_server_thrift_handler, as: WoodyHandler
@type id :: atom
defmodule Endpoint do
@moduledoc """
An IP endpoint consisting of socket address and port number.
"""
@type family :: :inet.address_family()
@type t :: %__MODULE__{
ip: :inet.socket_address(),
port: :inet.port_number(),
family: family
}
defstruct ip: {0, 0, 0, 0}, port: 0, family: :inet
@doc "Creates a local endpoint to listen on. Port will be assigned by the host system."
@spec loopback(family) :: t
def loopback(family \\ :inet), do: %__MODULE__{ip: :loopback, port: 0, family: family}
@doc "Creates an endpoint to listen on all network interfaces. Port will be assigned by the host system."
@spec any(family) :: t
def any(family), do: %__MODULE__{ip: :any, port: 0, family: family}
defimpl String.Chars do
@spec to_string(Endpoint.t) :: String.t
def to_string(%Endpoint{ip: ip, port: port, family: family}) do
"#{:inet.ntoa(:inet.translate_ip(ip, family))}:#{port}"
end
end
end
defmodule Handler do
@moduledoc """
A single Woody RPC handler for a [Thrift service](`Woody.Thrift.service`). This module defines
a behaviour your modules have to implement. Using modules generated with
(`defservice/2`)[`Woody.Server.Builder.defservice/2`] macro implement this behaviour
automatically.
"""
@type args :: tuple
@type hdlopts :: any
@type throws(_exception) :: no_return
@type t :: WoodyServer.route(map)
@callback service() :: Woody.Thrift.service
@callback handle_function(Woody.Thrift.tfunction, args, Woody.Context.t, hdlopts) ::
any | throws(any)
defmodule Adapter do
@moduledoc false
@behaviour WoodyHandler
@impl true
def handle_function(function, args, ctx, {innermod, hdlopts}) do
{:ok, innermod.handle_function(function, args, ctx, hdlopts)}
end
def handle_function(function, args, ctx, innermod) do
handle_function(function, args, ctx, {innermod, nil})
end
end
@spec new(module | {module, hdlopts}, String.t, Keyword.t) :: t
def new(module, http_path, options \\ []) do
adapter = {Adapter, module}
opts = %{
protocol: :thrift,
transport: :http,
handlers: [{http_path, {module.service(), adapter}}],
event_handler: []
}
opts = options |> Enum.reduce(opts, fn
({:event_handler, evh}, opts) ->
%{opts | event_handler: List.wrap(evh)}
({:read_body_opts, read_body_opts}, opts) when is_map(read_body_opts) ->
%{opts | read_body_opts: read_body_opts}
({:limits, limits}, opts) when is_map(limits) ->
%{opts | handler_limits: limits}
end)
opts
|> WoodyServer.get_routes()
|> List.first()
end
end
@spec child_spec(id, Endpoint.t, Handler.t | [Handler.t], Keyword.t) :: Supervisor.child_spec
def child_spec(id, endpoint, handlers, options \\ []) do
opts = %{
protocol: :thrift,
transport: :http,
handlers: [],
event_handler: [],
ip: endpoint.ip,
port: endpoint.port,
additional_routes: List.wrap(handlers)
}
opts = options |> Enum.reduce(opts, fn
({:transport_opts, transport_opts}, opts) when is_map(transport_opts) ->
%{opts | transport_opts: transport_opts}
({:protocol_opts, protocol_opts}, opts) when is_map(protocol_opts) ->
%{opts | protocol_opts: protocol_opts}
({:shutdown_timeout, timeout}, opts) when is_integer(timeout) and timeout >= 0 ->
%{opts | shutdown_timeout: timeout}
end)
WoodyServer.child_spec(id, opts)
end
@spec endpoint(id) :: Endpoint.t
def endpoint(id) do
{ip, port} = WoodyServer.get_addr(id)
%Endpoint{ip: ip, port: port}
end
end

113
lib/woody/thrift.ex Normal file
View File

@ -0,0 +1,113 @@
defmodule Woody.Thrift do
@moduledoc """
Utility functions to interact with Erlang code generated by `erlang` generator through
https://github.com/valitydev/thrift compiler.
"""
@typedoc """
A Thrift service: name of Erlang module generated with Thrift compiler + name of the service
itself.
"""
@type service :: {module, atom}
@typedoc """
Name of a Thrift function in some service.
"""
@type tfunction :: atom
@type field :: {tag, requiredness, ttype, name, _default :: any}
@type tag :: pos_integer
@type requiredness :: :required | :optional | :undefined
@type name :: atom
@type ttyperef :: {module, atom}
@type ttype ::
:bool |
:byte |
:i16 |
:i32 |
:i64 |
:string |
:double |
{:enum, ttyperef} |
{:struct, :struct | :union | :exception, ttyperef} |
{:list, ttype} |
{:set, ttype} |
{:map, ttype, ttype}
@spec get_service_functions(service) :: list(tfunction)
def get_service_functions({mod, service}) do
mod.functions(service)
end
@spec get_function_params(service, tfunction) :: list(field)
def get_function_params({mod, service}, function) do
{:struct, _, params} = mod.function_info(service, function, :params_type)
params
end
@spec get_function_reply(service, tfunction) :: ttype
def get_function_reply({mod, service}, function) do
mod.function_info(service, function, :reply_type)
end
@spec get_function_exceptions(service, tfunction) :: [ttype]
def get_function_exceptions({mod, service}, function) do
{:struct, _, fields} = mod.function_info(service, function, :exceptions)
for field <- fields do
get_field_type(field)
end
end
@spec get_field_name(field) :: name
def get_field_name({_n, _req, _type, name, _default}), do: name
@spec get_field_type(field) :: ttype
def get_field_type({_n, _req, type, _name, _default}), do: type
defmodule Header do
@moduledoc false
@spec import_records(Path.t, atom | [atom]) :: Macro.output
defmacro import_records(from, names) do
quote do
require Record
Enum.each(unquote(List.wrap(names)), fn name ->
Record.defrecord(name, Record.extract(name, from: unquote(from)))
end)
end
end
end
defmodule MacroHelpers do
@moduledoc false
@spec map_type(Woody.Thrift.ttype) :: Macro.t
def map_type(:byte), do: quote do: -0x80..0x7F
def map_type(:i8), do: quote do: -0x80..0x7F
def map_type(:i16), do: quote do: -0x8000..0x7FFF
def map_type(:i32), do: quote do: -0x80000000..0x7FFFFFFF
def map_type(:i64), do: quote do: -0x8000000000000000..0x7FFFFFFFFFFFFFFF
def map_type(:double), do: quote do: float
def map_type(:bool), do: quote do: bool
def map_type(:string), do: quote do: String.t
def map_type({:struct, _flavor, []}), do: :ok
def map_type({:struct, _flavor, {mod, name}}) do
quote do: unquote(mod).unquote(name)
end
def map_type({:enum, {mod, name}}) do
quote do: unquote(mod).unquote(name)
end
def map_type({:list, eltype}) do
quote do: [unquote(map_type(eltype))]
end
def map_type({:set, eltype}) do
quote do: [unquote(map_type(eltype))]
end
def map_type({:map, ktype, vtype}) do
quote do: %{unquote(map_type(ktype)) => unquote(map_type(vtype))}
end
end
end

29
mix.exs Normal file
View File

@ -0,0 +1,29 @@
defmodule Woody.MixProject do
use Mix.Project
def project do
[
app: :woody_ex,
version: "0.1.0",
elixir: "~> 1.13",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:woody, git: "https://github.com/valitydev/woody_erlang.git", branch: "master"},
{:dialyxir, "~> 1.2", only: [:dev], runtime: false},
{:credo, "~> 1.6", only: [:dev, :test], runtime: false}
]
end
end

25
mix.lock Normal file
View File

@ -0,0 +1,25 @@
%{
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
"cache": {:hex, :cache, "2.3.3", "b23a5fe7095445a88412a6e614c933377e0137b44ffed77c9b3fef1a731a20b2", [:rebar3], [], "hexpm", "44516ce6fa03594d3a2af025dd3a87bfe711000eb730219e1ddefc816e0aa2f4"},
"certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"},
"dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"genlib": {:git, "https://github.com/valitydev/genlib.git", "82c5ff3866e3019eb347c7f1d8f1f847bed28c10", [branch: "master"]},
"gproc": {:hex, :gproc, "0.9.0", "853ccb7805e9ada25d227a157ba966f7b34508f386a3e7e21992b1b484230699", [:rebar3], [], "hexpm", "587e8af698ccd3504cf4ba8d90f893ede2b0f58cabb8a916e2bf9321de3cf10b"},
"hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"snowflake": {:git, "https://github.com/valitydev/snowflake.git", "de159486ef40cec67074afe71882bdc7f7deab72", [branch: "master"]},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"thrift": {:git, "https://github.com/valitydev/thrift_erlang.git", "3f3e11246d90aefa8f58b35e4f2eab14c0c28bd2", [branch: "master"]},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"woody": {:git, "https://github.com/valitydev/woody_erlang.git", "e3ccc3765bd10adb15f309a4d4b44491c8f9509a", [branch: "master"]},
}

67
test/test.thrift Normal file
View File

@ -0,0 +1,67 @@
namespace erlang woody.test
struct Weapon {
1: required string name
2: required i16 slot_pos
3: optional i16 ammo
}
struct Powerup {
1: required string name
2: optional i16 level
3: optional i16 time_left
}
enum Direction {
NEXT = 1
PREV = 0
}
exception WeaponFailure {
1: required string exception_name = "weapon failure"
2: required string code
3: optional string reason
}
exception PowerupFailure {
1: required string exception_name = "powerup failure"
2: required string code
3: optional string reason
}
service Weapons {
Weapon switch_weapon (
1: Weapon current_weapon
2: Direction direction
3: i16 shift
4: binary data
) throws (
1: WeaponFailure error
)
Weapon get_weapon (
1: string name
2: binary data
) throws (
1: WeaponFailure error
)
void get_stuck_looping_weapons ()
}
service Powerups {
Powerup get_powerup (
1: string name
2: binary data
) throws (
1: PowerupFailure error
)
oneway void like_powerup (
1: string name
2: binary data
)
}

12
test/test_helper.exs Normal file
View File

@ -0,0 +1,12 @@
Mix.shell().cmd("mkdir -p test/gen")
Mix.shell().cmd("thrift --gen erlang:app_namespaces -out test/gen test/test.thrift")
:code.add_pathz('test/gen')
for file <- Path.wildcard("test/gen/*.erl") do
Mix.shell().info("Compiling generated file \"#{file}\"...")
:compile.file(
Mix.Compilers.Erlang.to_erl_file(file),
outdir: 'test/gen'
)
end
ExUnit.start()

123
test/woody_test.exs Normal file
View File

@ -0,0 +1,123 @@
defmodule WoodyTest do
use ExUnit.Case
# doctest Woody
alias Woody.Thrift.Header
require Header
Header.import_records("test/gen/woody_test_thrift.hrl", [
:test_Weapon,
:test_WeaponFailure,
:test_Powerup
])
defmodule Weapons do
import Woody.Server.Builder
require Woody.Server.Builder
defservice Service, {:woody_test_thrift, :Weapons}
defmodule Handler do
use Weapons.Service
require Header
Header.import_records("test/gen/woody_test_thrift.hrl", [
:test_Weapon,
:test_WeaponFailure
])
@impl Weapons.Service
def handle_switch_weapon(current, direction, shift, _data, _ctx, _hdlopts) do
test_Weapon(slot_pos: pos) = current
pos = if direction == :next, do: pos + shift, else: pos - shift
if pos > 0 do
test_Weapon(current, slot_pos: pos)
else
throw test_WeaponFailure(
code: "invalid_shift",
reason: "Shifted into #{pos} position"
)
end
end
@impl Weapons.Service
def handle_get_weapon("oops", _data, _ctx, _hdlopts) do
42 = 1337
end
def handle_get_weapon(name, _data, _ctx, _hdlopts) do
test_Weapon(name: name, slot_pos: 42, ammo: 9001)
end
@impl Weapons.Service
def handle_get_stuck_looping_weapons(_ctx, _hdlopts) do
:ok
end
end
defmodule Server do
alias Woody.Server.Http
def child_spec(id) do
handler = Http.Handler.new(Handler, "/weapons", event_handler: :woody_event_handler_default)
Http.child_spec(id, Http.Endpoint.loopback(), handler)
end
end
defmodule Client do
use Woody.Client.Builder, service: {:woody_test_thrift, :Weapons}
end
end
setup_all do
{:ok, pid} = Supervisor.start_link([{Weapons.Server, __MODULE__}], strategy: :one_for_one)
endpoint = Woody.Server.Http.endpoint(__MODULE__)
url = "http://#{endpoint}/weapons"
[
supervisor: pid,
endpoint: Woody.Server.Http.endpoint(__MODULE__),
url: url
]
end
setup context do
trace_id = context[:test] |> to_string() |> String.slice(0, 64)
woody_ctx = Woody.Context.new(trace_id: trace_id)
client = Woody.Client.Http.new(woody_ctx, context[:url], event_handler: :woody_event_handler_default)
[client: client]
end
test "gets weapon", context do
assert {:ok, test_Weapon(name: "blarg")} = Weapons.Client.get_weapon(context[:client], "blarg", "<data>")
end
test "switches weapon", context do
weapon = test_Weapon(name: "blarg", slot_pos: 42, ammo: 9001)
assert {:ok, test_Weapon(name: "blarg", slot_pos: 43, ammo: 9001)}
= Weapons.Client.switch_weapon(context[:client], weapon, :next, 1, "<data>")
end
test "fails weapon switch", context do
weapon = test_Weapon(name: "blarg", slot_pos: 42, ammo: 9001)
assert {:exception, test_WeaponFailure(code: "invalid_shift")}
= Weapons.Client.switch_weapon(context[:client], weapon, :prev, 50, "<data>")
end
test "receives unexpected error", context do
assert_raise Woody.UnexpectedError, ~r/^received an unexpected error/, fn ->
Weapons.Client.get_weapon(context[:client], "oops", "<data>")
end
end
test "receives unavailable resource", context do
trace_id = context[:test] |> to_string() |> String.slice(0, 64)
woody_ctx = Woody.Context.new(trace_id: trace_id)
url = "http://there.should.be.no.such.domain/"
client = Woody.Client.Http.new(woody_ctx, url, event_handler: :woody_event_handler_default)
assert_raise Woody.BadResultError, ~r/^got no result, resource unavailable/, fn ->
Weapons.Client.get_weapon(client, "blarg", "<data>")
end
end
test "void return", context do
assert {:ok, :ok} = Weapons.Client.get_stuck_looping_weapons(context[:client])
end
end