···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).
3434+- `deflexicon` now generates structs for errors defined by queries and
3535+ procedures, under a `Errors` submodule.
3636+- `deflexicon` generated models now have a `coerce_error/1` function that takes
3737+ in a map and tries to convert it to one of its known error structs.
3838+- `Atex.XRPC.Error` struct for wrapping XRPC error responses, including both
3939+ known errors (with typed `error_struct`) and unknown errors.
34403541### Fixed
3642
+239-14
lib/atex/lexicon.ex
···103103 end
104104105105 struct_def =
106106- if schema_key == :main do
107107- quoted_struct
108108- else
109109- nested_module_name =
110110- schema_key
111111- |> Recase.to_pascal()
112112- |> atomise()
106106+ cond do
107107+ schema_key == :main ->
108108+ quoted_struct
109109+110110+ schema_key == :errors ->
111111+ quoted_struct
112112+113113+ true ->
114114+ nested_module_name =
115115+ schema_key
116116+ |> Recase.to_pascal()
117117+ |> atomise()
113118114114- quote do
115115- defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do
116116- unquote(quoted_struct)
119119+ quote do
120120+ defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do
121121+ unquote(quoted_struct)
122122+ end
117123 end
118118- end
119124 end
120125121126 quote do
···265270 |> Map.from_struct()
266271 |> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end)
267272 |> Enum.into(%{})
268268- |> Jason.Encoder.encode(encoder)
273273+ |> JSON.Encoder.encode(encoder)
269274 end
270275 end
271276···305310 schema
306311 end
307312313313+ errors = build_errors_module(def[:errors])
314314+308315 # Root struct containing `params`
309316 main =
310317 if params do
···317324 quote do
318325 @enforce_keys [:params]
319326 defstruct params: nil
327327+328328+ unquote(coerce_error_function(errors))
320329 end
321330 }
322331 else
···328337 end,
329338 quote do
330339 defstruct []
340340+341341+ unquote(coerce_error_function(errors))
331342 end
332343 }
333344 end
334345335335- [main, params, output]
346346+ [main, params, output, errors]
336347 |> Enum.reject(&is_nil/1)
337348 end
338349···380391 def.input[:encoding]
381392 end
382393394394+ errors = build_errors_module(def[:errors])
395395+383396 # Root struct containing `input`, `raw_input`, and `params`
384397 main =
385398 {
···410423 params && input ->
411424 quote do
412425 defstruct input: nil, params: nil
426426+427427+ unquote(coerce_error_function(errors))
413428 end
414429415430 input ->
416431 quote do
417432 defstruct input: nil
433433+434434+ unquote(coerce_error_function(errors))
418435 end
419436420437 params && raw_input_encoding ->
···423440424441 @spec content_type() :: String.t()
425442 def content_type, do: unquote(raw_input_encoding)
443443+444444+ unquote(coerce_error_function(errors))
426445 end
427446428447 raw_input_encoding ->
···431450432451 @spec content_type() :: String.t()
433452 def content_type, do: unquote(raw_input_encoding)
453453+454454+ unquote(coerce_error_function(errors))
434455 end
435456436457 params ->
437458 quote do
438459 defstruct raw_input: nil, params: nil
460460+461461+ unquote(coerce_error_function(errors))
439462 end
440463441464 true ->
442465 quote do
443466 defstruct raw_input: nil
467467+468468+ unquote(coerce_error_function(errors))
444469 end
445470 end
446471 }
447472448448- [main, params, output, input]
473473+ [main, params, output, input, errors]
449474 |> Enum.reject(&is_nil/1)
450475 end
451476···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: []
794794+795795+ @spec build_errors_module(errors :: list(map()) | nil) ::
796796+ {atom(), term(), term(), term()} | nil
797797+ defp build_errors_module(errors) when errors == nil or errors == [], do: nil
798798+799799+ defp build_errors_module(errors) do
800800+ error_name_atoms = Enum.map(errors, fn %{name: name} -> atomise(name) end)
801801+802802+ error_names_type =
803803+ case error_name_atoms do
804804+ [] ->
805805+ quote(do: nil)
806806+807807+ [single] ->
808808+ {{:., [], [{:__aliases__, [alias: false], [single]}, :t]}, [], []}
809809+810810+ multiple ->
811811+ {:|, [],
812812+ [
813813+ {:|, [],
814814+ Enum.map(multiple, fn atom ->
815815+ {{:., [], [{:__aliases__, [alias: false], [atom]}, :t]}, [], []}
816816+ end)},
817817+ nil
818818+ ]}
819819+ end
820820+821821+ error_structs =
822822+ Enum.map(errors, fn %{name: name} = error_def ->
823823+ error_name = atomise(name)
824824+ description = Map.get(error_def, :description)
825825+826826+ quoted_struct =
827827+ quote do
828828+ defmodule unquote({:__aliases__, [alias: false], [error_name]}) do
829829+ @moduledoc false
830830+ @enforce_keys []
831831+ defstruct message: nil
832832+833833+ @type t :: %__MODULE__{message: String.t() | nil}
834834+835835+ @spec from_json(map()) :: {:ok, t()} | {:error, :not_this_error}
836836+ def from_json(%{"error" => unquote(name), "message" => msg})
837837+ when is_binary(msg) or is_nil(msg) do
838838+ {:ok, %__MODULE__{message: msg}}
839839+ end
840840+841841+ def from_json(%{"error" => unquote(name)}),
842842+ do: {:ok, %__MODULE__{message: nil}}
843843+844844+ def from_json(_), do: {:error, :not_this_error}
845845+846846+ defimpl JSON.Encoder do
847847+ def encode(%{mesage: message}, encoder) do
848848+ %{"error" => unquote(name)}
849849+ |> then(&if(message, do: Map.put(&1, "message", message), else: &1))
850850+ |> JSON.Encoder.encode(encoder)
851851+ end
852852+ end
853853+854854+ defimpl Jason.Encoder do
855855+ def encode(%{message: message}, options) do
856856+ %{"error" => unquote(name)}
857857+ |> then(&if(message, do: Map.put(&1, "message", message), else: &1))
858858+ |> Jason.Encode.map(options)
859859+ end
860860+ end
861861+862862+ unquote(if(description, do: quote(do: @doc(unquote(description))), else: nil))
863863+864864+ def error_name, do: unquote(name)
865865+ end
866866+ end
867867+868868+ quoted_name =
869869+ quote do
870870+ unquote(error_name)
871871+ end
872872+873873+ {quoted_name, quoted_struct}
874874+ end)
875875+876876+ coerce_function_body =
877877+ if error_name_atoms == [] do
878878+ quote do: nil
879879+ else
880880+ error_module_refs =
881881+ Enum.map(error_name_atoms, fn name ->
882882+ {:__aliases__, [alias: false], [name]}
883883+ end)
884884+885885+ quoted_error_name_atoms =
886886+ Enum.map(error_name_atoms, fn name ->
887887+ quote do
888888+ unquote(name)
889889+ end
890890+ end)
891891+892892+ quote do
893893+ @type error_struct :: unquote(error_names_type)
894894+895895+ @spec coerce(map()) ::
896896+ {:ok, error_struct(), String.t()} | {:error, :no_matching_error}
897897+ def coerce(body) when is_map(body) do
898898+ result =
899899+ Enum.find_value(unquote(error_module_refs), fn error_module ->
900900+ case apply(error_module, :from_json, [body]) do
901901+ {:ok, _} = ok -> ok
902902+ {:error, :not_this_error} -> nil
903903+ end
904904+ end)
905905+906906+ case result do
907907+ {:ok, struct} ->
908908+ error_name =
909909+ Enum.find_value(unquote(quoted_error_name_atoms), fn error_name ->
910910+ error_module = Module.concat(__MODULE__, error_name)
911911+912912+ case error_module.from_json(body) do
913913+ {:ok, _} -> error_name
914914+ {:error, :not_this_error} -> nil
915915+ end
916916+ end)
917917+918918+ {:ok, struct, error_name}
919919+920920+ nil ->
921921+ {:error, :no_matching_error}
922922+ end
923923+ end
924924+925925+ def coerce(_), do: {:error, :no_matching_error}
926926+ end
927927+ end
928928+929929+ errors_module =
930930+ if error_name_atoms == [] do
931931+ nil
932932+ else
933933+ quoted_structs = Enum.map(error_structs, fn {_, quoted} -> quoted end)
934934+935935+ quote do
936936+ defmodule Errors do
937937+ @moduledoc false
938938+939939+ unquote_splicing(quoted_structs)
940940+941941+ unquote(coerce_function_body)
942942+ end
943943+ end
944944+ end
945945+946946+ {:errors, nil, nil, errors_module}
947947+ end
948948+949949+ @spec coerce_error_function({atom(), term(), term(), term()} | nil) :: term()
950950+ defp coerce_error_function(nil) do
951951+ quote do
952952+ @spec coerce_error(map()) :: {:error, :no_errors_defined}
953953+ def coerce_error(%{}), do: {:error, :no_errors_defined}
954954+ def coerce_error(_), do: {:error, :no_errors_defined}
955955+ end
956956+ end
957957+958958+ defp coerce_error_function({:errors, _, _, _}) do
959959+ quote do
960960+ @spec coerce_error(map()) ::
961961+ {:ok, Atex.XRPC.Error.t()} | {:error, :unknown_error | :not_an_error}
962962+ def coerce_error(%{"error" => _} = body) do
963963+ errors_module = Module.concat(__MODULE__, Errors)
964964+965965+ case errors_module.coerce(body) do
966966+ {:ok, error_struct, error_name} ->
967967+ {:ok,
968968+ %Atex.XRPC.Error{
969969+ error: to_string(error_name),
970970+ message: error_struct.message,
971971+ error_struct: error_struct
972972+ }}
973973+974974+ {:error, :no_matching_error} ->
975975+ error_name =
976976+ body
977977+ |> Map.take(["error", "message"])
978978+ |> Enum.map(fn {k, v} -> {String.to_atom(k), v} end)
979979+ |> Keyword.get_values(:error)
980980+ |> List.first()
981981+982982+ {:error,
983983+ %Atex.XRPC.Error{
984984+ error: to_string(error_name),
985985+ message: Map.get(body, "message"),
986986+ error_struct: nil
987987+ }}
988988+ end
989989+ end
990990+991991+ def coerce_error(_), do: {:error, :not_an_error}
992992+ end
993993+ end
769994end
+48-3
lib/atex/xrpc.ex
···10101111 # Login-based client
1212 {:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password")
1313- {:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
1313+ {:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"})
14141515 # OAuth-based client
1616 {:ok, oauth_client} = Atex.XRPC.OAuthClient.from_conn(conn)
1717- {:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
1717+ {:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"})
18181919 ## Unauthenticated requests
20202121- Unauthenticated functions (`unauthed_get/3`, `unauthed_post/3`) do not require a client
2121+ Unauthenticated functions (`unauthed_get/3`, `unauthed_post/3`) are do not require a client
2222 and work directly with endpoints:
23232424 {:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."])
2525+2626+ ## Error handling
2727+2828+ When using lexicon structs, error responses are automatically coerced into
2929+ `Atex.XRPC.Error` structs. If the error matches a lexicon-defined error,
3030+ the specific error struct will be available via the `error_struct` field.
3131+3232+ {:ok, %Atex.XRPC.Error{error: "SomethingBroke", message: msg, error_struct: specific_error}, client}
2533 """
26342735 alias Atex.XRPC.Client
3636+ alias Atex.XRPC.Error
28372938 @doc """
3039 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
···6372 opts = put_params(opts, query)
6473 output_struct = Module.concat(module, Output)
6574 output_exists = Code.ensure_loaded?(output_struct)
7575+ coerce_exists = function_exported?(module, :coerce_error, 1)
66766777 case client.__struct__.get(client, module.id(), opts) do
6878 {:ok, %{status: 200} = response, client} ->
···7888 {:ok, response, client}
7989 end
80909191+ {:ok, %{body: %{"error" => _}} = response, client} when coerce_exists ->
9292+ case module.coerce_error(response.body) do
9393+ {:ok, %Error{} = error} ->
9494+ {:ok, %{response | body: error}, client}
9595+9696+ {:error, %Error{} = error} ->
9797+ {:error, error, client}
9898+ end
9999+100100+ {:ok, %{body: %{"error" => error} = body}, client} ->
101101+ {:error,
102102+ %Error{
103103+ error: error,
104104+ message: Map.get(body, "message"),
105105+ error_struct: nil
106106+ }, client}
107107+81108 {:ok, _, _} = ok ->
82109 ok
83110···148175149176 output_struct = Module.concat(module, Output)
150177 output_exists = Code.ensure_loaded?(output_struct)
178178+ coerce_exists = function_exported?(module, :coerce_error, 1)
151179152180 case client.__struct__.post(client, module.id(), opts) do
153181 {:ok, %{status: 200} = response, client} ->
···162190 else
163191 {:ok, response, client}
164192 end
193193+194194+ {:ok, %{body: %{"error" => _}} = response, client} when coerce_exists ->
195195+ case module.coerce_error(response.body) do
196196+ {:ok, %Error{} = error} ->
197197+ {:ok, %{response | body: error}, client}
198198+199199+ {:error, %Error{} = error} ->
200200+ {:error, error, client}
201201+ end
202202+203203+ {:ok, %{body: %{"error" => error} = body}, client} ->
204204+ {:error,
205205+ %Error{
206206+ error: error,
207207+ message: Map.get(body, "message"),
208208+ error_struct: nil
209209+ }, client}
165210166211 {:ok, _, _} = ok ->
167212 ok
+41
lib/atex/xrpc/error.ex
···11+defmodule Atex.XRPC.Error do
22+ @moduledoc """
33+ Represents an XRPC error response.
44+55+ When a lexicon defines errors for a query or procedure, the XRPC client will
66+ attempt to coerce error responses into typed error structs. If the error
77+ matches a known lexicon error, `error_struct` will contain the specific struct.
88+ If the error is unknown, `error_struct` will be `nil`.
99+1010+ ## XRPC Error Response Format
1111+1212+ Per the XRPC spec, error responses have the following JSON structure:
1313+1414+ ```json
1515+ {
1616+ "error": "ErrorName",
1717+ "message": "Human-readable description"
1818+ }
1919+ ```
2020+2121+ ## Examples
2222+2323+ %Atex.XRPC.Error{error: "SomethingBroke", message: "Database connection failed"}
2424+2525+ # With a typed error struct
2626+ %Atex.XRPC.Error{
2727+ error: "SomethingBroke",
2828+ message: "Database connection failed",
2929+ error_struct: %Com.Example.DoThing.Errors.SomethingBroke{message: "Database connection failed"}
3030+ }
3131+ """
3232+3333+ @enforce_keys [:error]
3434+ defstruct [:error, :message, :error_struct]
3535+3636+ @type t :: %__MODULE__{
3737+ error: String.t(),
3838+ message: String.t() | nil,
3939+ error_struct: module() | nil
4040+ }
4141+end
+134
test/atex/lexicon_test.exs
···180180 assert {:error, :no_matching_type} = Lexicon.Test.GetUnion.Output.from_json(%{})
181181 end
182182 end
183183+184184+ # ---------------------------------------------------------------------------
185185+ # Tests: query with errors
186186+ # ---------------------------------------------------------------------------
187187+188188+ describe "query with defined errors" do
189189+ test "generates an Errors submodule" do
190190+ assert Code.ensure_loaded?(Lexicon.Test.DoThing.Errors)
191191+ end
192192+193193+ test "Errors submodule has error structs" do
194194+ assert Code.ensure_loaded?(Lexicon.Test.DoThing.Errors.SomethingBroke)
195195+ assert Code.ensure_loaded?(Lexicon.Test.DoThing.Errors.DoesNotCompute)
196196+ end
197197+198198+ test "error structs have from_json/1" do
199199+ assert function_exported?(Lexicon.Test.DoThing.Errors.SomethingBroke, :from_json, 1)
200200+ assert function_exported?(Lexicon.Test.DoThing.Errors.DoesNotCompute, :from_json, 1)
201201+ end
202202+203203+ test "error structs have error_name/0" do
204204+ assert Lexicon.Test.DoThing.Errors.SomethingBroke.error_name() == "SomethingBroke"
205205+ assert Lexicon.Test.DoThing.Errors.DoesNotCompute.error_name() == "DoesNotCompute"
206206+ end
207207+208208+ test "error from_json matches error with message" do
209209+ assert {:ok, error} =
210210+ Lexicon.Test.DoThing.Errors.SomethingBroke.from_json(%{
211211+ "error" => "SomethingBroke",
212212+ "message" => "Database connection failed"
213213+ })
214214+215215+ assert error.message == "Database connection failed"
216216+ end
217217+218218+ test "error from_json matches error without message" do
219219+ assert {:ok, error} =
220220+ Lexicon.Test.DoThing.Errors.SomethingBroke.from_json(%{
221221+ "error" => "SomethingBroke"
222222+ })
223223+224224+ assert error.message == nil
225225+ end
226226+227227+ test "error from_json returns error for non-matching error name" do
228228+ assert {:error, :not_this_error} =
229229+ Lexicon.Test.DoThing.Errors.SomethingBroke.from_json(%{
230230+ "error" => "DoesNotCompute"
231231+ })
232232+ end
233233+234234+ test "root module exports coerce_error/1" do
235235+ assert function_exported?(Lexicon.Test.DoThing, :coerce_error, 1)
236236+ end
237237+238238+ test "coerce_error/1 matches a known error" do
239239+ assert {:ok, %Atex.XRPC.Error{error: "SomethingBroke", error_struct: error_struct}} =
240240+ Lexicon.Test.DoThing.coerce_error(%{
241241+ "error" => "SomethingBroke",
242242+ "message" => "It broke"
243243+ })
244244+245245+ assert error_struct.__struct__.error_name() == "SomethingBroke"
246246+ assert error_struct.message == "It broke"
247247+ end
248248+249249+ test "coerce_error/1 returns error for unknown error" do
250250+ assert {:error, %Atex.XRPC.Error{error: "UnknownError", error_struct: nil}} =
251251+ Lexicon.Test.DoThing.coerce_error(%{
252252+ "error" => "UnknownError",
253253+ "message" => "What happened?"
254254+ })
255255+ end
256256+257257+ test "coerce_error/1 returns error for non-error body" do
258258+ assert {:error, :not_an_error} = Lexicon.Test.DoThing.coerce_error(%{"data" => "value"})
259259+ end
260260+ end
261261+262262+ # ---------------------------------------------------------------------------
263263+ # Tests: procedure with errors
264264+ # ---------------------------------------------------------------------------
265265+266266+ describe "procedure with defined errors" do
267267+ test "generates an Errors submodule" do
268268+ assert Code.ensure_loaded?(Lexicon.Test.DoOtherThing.Errors)
269269+ end
270270+271271+ test "error structs have from_json/1" do
272272+ Code.ensure_loaded!(Lexicon.Test.DoOtherThing)
273273+ assert function_exported?(Lexicon.Test.DoOtherThing.Errors.ValidationFailed, :from_json, 1)
274274+ end
275275+276276+ test "root module exports coerce_error/1" do
277277+ Code.ensure_loaded!(Lexicon.Test.DoOtherThing)
278278+ assert function_exported?(Lexicon.Test.DoOtherThing, :coerce_error, 1)
279279+ end
280280+281281+ test "coerce_error/1 matches a known error" do
282282+ assert {:ok, %Atex.XRPC.Error{error: "ValidationFailed", error_struct: _}} =
283283+ Lexicon.Test.DoOtherThing.coerce_error(%{
284284+ "error" => "ValidationFailed",
285285+ "message" => "Invalid data"
286286+ })
287287+ end
288288+ end
289289+290290+ # ---------------------------------------------------------------------------
291291+ # Tests: error struct JSON encoding
292292+ # ---------------------------------------------------------------------------
293293+294294+ describe "error struct JSON encoding" do
295295+ test "encodes with message" do
296296+ {:ok, error_struct} =
297297+ Lexicon.Test.DoThing.Errors.SomethingBroke.from_json(%{
298298+ "error" => "SomethingBroke",
299299+ "message" => "Test message"
300300+ })
301301+302302+ json = Jason.encode!(error_struct)
303303+ assert json =~ ~s("error":"SomethingBroke")
304304+ assert json =~ ~s("message":"Test message")
305305+ end
306306+307307+ test "encodes without message" do
308308+ {:ok, error_struct} =
309309+ Lexicon.Test.DoThing.Errors.SomethingBroke.from_json(%{
310310+ "error" => "SomethingBroke"
311311+ })
312312+313313+ json = Jason.encode!(error_struct)
314314+ assert json == ~s({"error":"SomethingBroke"})
315315+ end
316316+ end
183317end