Skip to content

Commit d1910e7

Browse files
committed
feat(deflexicon): generate structs for query/procedure errors
1 parent aed30f1 commit d1910e7

6 files changed

Lines changed: 527 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ and this project adheres to
3131
- `/logout` route for `Atex.OAuth.Plug` to revoke the current session, as well
3232
as `Atex.OAuth.Plug.revoke_session/2` to revoke a conn's session
3333
programmaticly (e.g. from a session management dashboard).
34+
- `deflexicon` now generates structs for errors defined by queries and
35+
procedures, under a `Errors` submodule.
36+
- `deflexicon` generated models now have a `coerce_error/1` function that takes
37+
in a map and tries to convert it to one of its known error structs.
38+
- `Atex.XRPC.Error` struct for wrapping XRPC error responses, including both
39+
known errors (with typed `error_struct`) and unknown errors.
3440

3541
### Fixed
3642

lib/atex/lexicon.ex

Lines changed: 240 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -103,19 +103,24 @@ defmodule Atex.Lexicon do
103103
end
104104

105105
struct_def =
106-
if schema_key == :main do
107-
quoted_struct
108-
else
109-
nested_module_name =
110-
schema_key
111-
|> Recase.to_pascal()
112-
|> atomise()
113-
114-
quote do
115-
defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do
116-
unquote(quoted_struct)
106+
cond do
107+
schema_key == :main ->
108+
quoted_struct
109+
110+
schema_key == :errors ->
111+
quoted_struct
112+
113+
true ->
114+
nested_module_name =
115+
schema_key
116+
|> Recase.to_pascal()
117+
|> atomise()
118+
119+
quote do
120+
defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do
121+
unquote(quoted_struct)
122+
end
117123
end
118-
end
119124
end
120125

121126
quote do
@@ -265,7 +270,7 @@ defmodule Atex.Lexicon do
265270
|> Map.from_struct()
266271
|> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end)
267272
|> Enum.into(%{})
268-
|> Jason.Encoder.encode(encoder)
273+
|> JSON.Encoder.encode(encoder)
269274
end
270275
end
271276

@@ -305,6 +310,8 @@ defmodule Atex.Lexicon do
305310
schema
306311
end
307312

313+
errors = build_errors_module(def[:errors])
314+
308315
# Root struct containing `params`
309316
main =
310317
if params do
@@ -317,6 +324,8 @@ defmodule Atex.Lexicon do
317324
quote do
318325
@enforce_keys [:params]
319326
defstruct params: nil
327+
328+
unquote(coerce_error_function(errors))
320329
end
321330
}
322331
else
@@ -328,11 +337,13 @@ defmodule Atex.Lexicon do
328337
end,
329338
quote do
330339
defstruct []
340+
341+
unquote(coerce_error_function(errors))
331342
end
332343
}
333344
end
334345

335-
[main, params, output]
346+
[main, params, output, errors]
336347
|> Enum.reject(&is_nil/1)
337348
end
338349

@@ -380,6 +391,8 @@ defmodule Atex.Lexicon do
380391
def.input[:encoding]
381392
end
382393

394+
errors = build_errors_module(def[:errors])
395+
383396
# Root struct containing `input`, `raw_input`, and `params`
384397
main =
385398
{
@@ -410,11 +423,15 @@ defmodule Atex.Lexicon do
410423
params && input ->
411424
quote do
412425
defstruct input: nil, params: nil
426+
427+
unquote(coerce_error_function(errors))
413428
end
414429

415430
input ->
416431
quote do
417432
defstruct input: nil
433+
434+
unquote(coerce_error_function(errors))
418435
end
419436

420437
params && raw_input_encoding ->
@@ -423,6 +440,8 @@ defmodule Atex.Lexicon do
423440

424441
@spec content_type() :: String.t()
425442
def content_type, do: unquote(raw_input_encoding)
443+
444+
unquote(coerce_error_function(errors))
426445
end
427446

428447
raw_input_encoding ->
@@ -431,21 +450,27 @@ defmodule Atex.Lexicon do
431450

432451
@spec content_type() :: String.t()
433452
def content_type, do: unquote(raw_input_encoding)
453+
454+
unquote(coerce_error_function(errors))
434455
end
435456

436457
params ->
437458
quote do
438459
defstruct raw_input: nil, params: nil
460+
461+
unquote(coerce_error_function(errors))
439462
end
440463

441464
true ->
442465
quote do
443466
defstruct raw_input: nil
467+
468+
unquote(coerce_error_function(errors))
444469
end
445470
end
446471
}
447472

448-
[main, params, output, input]
473+
[main, params, output, input, errors]
449474
|> Enum.reject(&is_nil/1)
450475
end
451476

@@ -766,4 +791,204 @@ defmodule Atex.Lexicon do
766791
defp do_join_with_pipe([head]), do: [head]
767792
defp do_join_with_pipe([head | tail]), do: [{:|, [], [head | do_join_with_pipe(tail)]}]
768793
defp do_join_with_pipe([]), do: []
794+
795+
@spec build_errors_module(errors :: list(map()) | nil) ::
796+
{atom(), term(), term(), term()} | nil
797+
defp build_errors_module(errors) when errors == nil or errors == [], do: nil
798+
799+
defp build_errors_module(errors) do
800+
error_name_atoms = Enum.map(errors, fn %{name: name} -> atomise(name) end)
801+
802+
error_names_type =
803+
case error_name_atoms do
804+
[] ->
805+
quote(do: nil)
806+
807+
[single] ->
808+
{{:., [], [{:__aliases__, [alias: false], [single]}, :t]}, [], []}
809+
810+
multiple ->
811+
{:|, [],
812+
[
813+
{:|, [],
814+
Enum.map(multiple, fn atom ->
815+
{{:., [], [{:__aliases__, [alias: false], [atom]}, :t]}, [], []}
816+
end)},
817+
nil
818+
]}
819+
end
820+
821+
error_structs =
822+
Enum.map(errors, fn %{name: name} = error_def ->
823+
error_name = atomise(name)
824+
description = Map.get(error_def, :description)
825+
826+
quoted_struct =
827+
quote do
828+
defmodule unquote({:__aliases__, [alias: false], [error_name]}) do
829+
@moduledoc false
830+
@enforce_keys []
831+
defstruct message: nil
832+
833+
@type t :: %__MODULE__{message: String.t() | nil}
834+
835+
@spec from_json(map()) :: {:ok, t()} | {:error, :not_this_error}
836+
def from_json(%{"error" => unquote(name), "message" => msg})
837+
when is_binary(msg) or is_nil(msg) do
838+
{:ok, %__MODULE__{message: msg}}
839+
end
840+
841+
def from_json(%{"error" => unquote(name)}),
842+
do: {:ok, %__MODULE__{message: nil}}
843+
844+
def from_json(_), do: {:error, :not_this_error}
845+
846+
defimpl JSON.Encoder do
847+
def encode(%{mesage: message}, encoder) do
848+
%{"error" => unquote(name)}
849+
|> then(&if(message, do: Map.put(&1, "message", message), else: &1))
850+
|> JSON.Encoder.encode(encoder)
851+
end
852+
end
853+
854+
defimpl Jason.Encoder do
855+
def encode(%{message: message}, options) do
856+
%{"error" => unquote(name)}
857+
|> then(&if(message, do: Map.put(&1, "message", message), else: &1))
858+
|> Jason.Encode.map(options)
859+
end
860+
end
861+
862+
unquote(if(description, do: quote(do: @doc(unquote(description))), else: nil))
863+
864+
def error_name, do: unquote(name)
865+
end
866+
end
867+
868+
quoted_name =
869+
quote do
870+
unquote(error_name)
871+
end
872+
873+
{quoted_name, quoted_struct}
874+
end)
875+
876+
coerce_function_body =
877+
if error_name_atoms == [] do
878+
quote do: nil
879+
else
880+
error_module_refs =
881+
Enum.map(error_name_atoms, fn name ->
882+
{:__aliases__, [alias: false], [name]}
883+
end)
884+
885+
quoted_error_name_atoms =
886+
Enum.map(error_name_atoms, fn name ->
887+
quote do
888+
unquote(name)
889+
end
890+
end)
891+
892+
quote do
893+
@type error_struct :: unquote(error_names_type)
894+
895+
@spec coerce(map()) ::
896+
{:ok, error_struct(), String.t()} | {:error, :no_matching_error}
897+
def coerce(body) when is_map(body) do
898+
result =
899+
Enum.find_value(unquote(error_module_refs), fn error_module ->
900+
case apply(error_module, :from_json, [body]) do
901+
{:ok, _} = ok -> ok
902+
{:error, :not_this_error} -> nil
903+
end
904+
end)
905+
906+
case result do
907+
{:ok, struct} ->
908+
error_name =
909+
Enum.find_value(unquote(quoted_error_name_atoms), fn error_name ->
910+
error_module = Module.concat(__MODULE__, error_name)
911+
912+
case error_module.from_json(body) do
913+
{:ok, _} -> error_name
914+
{:error, :not_this_error} -> nil
915+
end
916+
end)
917+
918+
{:ok, struct, error_name}
919+
920+
nil ->
921+
{:error, :no_matching_error}
922+
end
923+
end
924+
925+
def coerce(_), do: {:error, :no_matching_error}
926+
end
927+
end
928+
929+
errors_module =
930+
if error_name_atoms == [] do
931+
nil
932+
else
933+
quoted_structs = Enum.map(error_structs, fn {_, quoted} -> quoted end)
934+
935+
quote do
936+
defmodule Errors do
937+
@moduledoc false
938+
939+
unquote_splicing(quoted_structs)
940+
941+
unquote(coerce_function_body)
942+
end
943+
end
944+
end
945+
946+
{:errors, nil, nil, errors_module}
947+
end
948+
949+
@spec coerce_error_function({atom(), term(), term(), term()} | nil) :: term()
950+
defp coerce_error_function(nil) do
951+
quote do
952+
@spec coerce_error(map()) :: {:error, :no_errors_defined}
953+
def coerce_error(%{}), do: {:error, :no_errors_defined}
954+
def coerce_error(_), do: {:error, :no_errors_defined}
955+
end
956+
end
957+
958+
defp coerce_error_function({:errors, _, _, _}) do
959+
quote do
960+
@spec coerce_error(map()) ::
961+
{:ok, Atex.XRPC.Error.t()} | {:error, :unknown_error | :not_an_error}
962+
def coerce_error(%{"error" => _} = body) do
963+
errors_module = Module.concat(__MODULE__, Errors)
964+
965+
case errors_module.coerce(body) do
966+
{:ok, error_struct, error_name} ->
967+
{:ok,
968+
%Atex.XRPC.Error{
969+
error: to_string(error_name),
970+
message: error_struct.message,
971+
error_struct: error_struct
972+
}}
973+
974+
{:error, :no_matching_error} ->
975+
error_name =
976+
body
977+
|> Map.take(["error", "message"])
978+
|> Enum.map(fn {k, v} -> {String.to_atom(k), v} end)
979+
|> Keyword.get_values(:error)
980+
|> List.first()
981+
982+
{:error,
983+
%Atex.XRPC.Error{
984+
error: to_string(error_name),
985+
message: Map.get(body, "message"),
986+
error_struct: nil
987+
}}
988+
end
989+
end
990+
991+
def coerce_error(_), do: {:error, :not_an_error}
992+
end
993+
end
769994
end

0 commit comments

Comments
 (0)