mirror of
https://github.com/valitydev/woody_ex.git
synced 2024-11-06 00:05:17 +00:00
Draft Elixir Woody RPC library (#1)
This commit is contained in:
parent
f95903fcec
commit
783cfd0eb1
4
.formatter.exs
Normal file
4
.formatter.exs
Normal file
@ -0,0 +1,4 @@
|
||||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
|
90
lib/woody/client/builder.ex
Normal file
90
lib/woody/client/builder.ex
Normal 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
56
lib/woody/client/http.ex
Normal 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
49
lib/woody/context.ex
Normal 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
80
lib/woody/errors.ex
Normal 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
116
lib/woody/server/builder.ex
Normal 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
127
lib/woody/server/http.ex
Normal 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
113
lib/woody/thrift.ex
Normal 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
29
mix.exs
Normal 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
25
mix.lock
Normal 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
67
test/test.thrift
Normal 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
12
test/test_helper.exs
Normal 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
123
test/woody_test.exs
Normal 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
|
Loading…
Reference in New Issue
Block a user