An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(deflexicon): generate structs for query/procedure errors

+526 -17
+6
CHANGELOG.md
··· 31 31 - `/logout` route for `Atex.OAuth.Plug` to revoke the current session, as well 32 32 as `Atex.OAuth.Plug.revoke_session/2` to revoke a conn's session 33 33 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. 34 40 35 41 ### Fixed 36 42
+239 -14
lib/atex/lexicon.ex
··· 103 103 end 104 104 105 105 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() 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() 113 118 114 - quote do 115 - defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do 116 - unquote(quoted_struct) 119 + quote do 120 + defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do 121 + unquote(quoted_struct) 122 + end 117 123 end 118 - end 119 124 end 120 125 121 126 quote do ··· 265 270 |> Map.from_struct() 266 271 |> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end) 267 272 |> Enum.into(%{}) 268 - |> Jason.Encoder.encode(encoder) 273 + |> JSON.Encoder.encode(encoder) 269 274 end 270 275 end 271 276 ··· 305 310 schema 306 311 end 307 312 313 + errors = build_errors_module(def[:errors]) 314 + 308 315 # Root struct containing `params` 309 316 main = 310 317 if params do ··· 317 324 quote do 318 325 @enforce_keys [:params] 319 326 defstruct params: nil 327 + 328 + unquote(coerce_error_function(errors)) 320 329 end 321 330 } 322 331 else ··· 328 337 end, 329 338 quote do 330 339 defstruct [] 340 + 341 + unquote(coerce_error_function(errors)) 331 342 end 332 343 } 333 344 end 334 345 335 - [main, params, output] 346 + [main, params, output, errors] 336 347 |> Enum.reject(&is_nil/1) 337 348 end 338 349 ··· 380 391 def.input[:encoding] 381 392 end 382 393 394 + errors = build_errors_module(def[:errors]) 395 + 383 396 # Root struct containing `input`, `raw_input`, and `params` 384 397 main = 385 398 { ··· 410 423 params && input -> 411 424 quote do 412 425 defstruct input: nil, params: nil 426 + 427 + unquote(coerce_error_function(errors)) 413 428 end 414 429 415 430 input -> 416 431 quote do 417 432 defstruct input: nil 433 + 434 + unquote(coerce_error_function(errors)) 418 435 end 419 436 420 437 params && raw_input_encoding -> ··· 423 440 424 441 @spec content_type() :: String.t() 425 442 def content_type, do: unquote(raw_input_encoding) 443 + 444 + unquote(coerce_error_function(errors)) 426 445 end 427 446 428 447 raw_input_encoding -> ··· 431 450 432 451 @spec content_type() :: String.t() 433 452 def content_type, do: unquote(raw_input_encoding) 453 + 454 + unquote(coerce_error_function(errors)) 434 455 end 435 456 436 457 params -> 437 458 quote do 438 459 defstruct raw_input: nil, params: nil 460 + 461 + unquote(coerce_error_function(errors)) 439 462 end 440 463 441 464 true -> 442 465 quote do 443 466 defstruct raw_input: nil 467 + 468 + unquote(coerce_error_function(errors)) 444 469 end 445 470 end 446 471 } 447 472 448 - [main, params, output, input] 473 + [main, params, output, input, errors] 449 474 |> Enum.reject(&is_nil/1) 450 475 end 451 476 ··· 766 791 defp do_join_with_pipe([head]), do: [head] 767 792 defp do_join_with_pipe([head | tail]), do: [{:|, [], [head | do_join_with_pipe(tail)]}] 768 793 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 769 994 end
+48 -3
lib/atex/xrpc.ex
··· 10 10 11 11 # Login-based client 12 12 {:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password") 13 - {:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"]) 13 + {:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"}) 14 14 15 15 # OAuth-based client 16 16 {:ok, oauth_client} = Atex.XRPC.OAuthClient.from_conn(conn) 17 - {:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"]) 17 + {:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"}) 18 18 19 19 ## Unauthenticated requests 20 20 21 - Unauthenticated functions (`unauthed_get/3`, `unauthed_post/3`) do not require a client 21 + Unauthenticated functions (`unauthed_get/3`, `unauthed_post/3`) are do not require a client 22 22 and work directly with endpoints: 23 23 24 24 {:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."]) 25 + 26 + ## Error handling 27 + 28 + When using lexicon structs, error responses are automatically coerced into 29 + `Atex.XRPC.Error` structs. If the error matches a lexicon-defined error, 30 + the specific error struct will be available via the `error_struct` field. 31 + 32 + {:ok, %Atex.XRPC.Error{error: "SomethingBroke", message: msg, error_struct: specific_error}, client} 25 33 """ 26 34 27 35 alias Atex.XRPC.Client 36 + alias Atex.XRPC.Error 28 37 29 38 @doc """ 30 39 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons. ··· 63 72 opts = put_params(opts, query) 64 73 output_struct = Module.concat(module, Output) 65 74 output_exists = Code.ensure_loaded?(output_struct) 75 + coerce_exists = function_exported?(module, :coerce_error, 1) 66 76 67 77 case client.__struct__.get(client, module.id(), opts) do 68 78 {:ok, %{status: 200} = response, client} -> ··· 78 88 {:ok, response, client} 79 89 end 80 90 91 + {:ok, %{body: %{"error" => _}} = response, client} when coerce_exists -> 92 + case module.coerce_error(response.body) do 93 + {:ok, %Error{} = error} -> 94 + {:ok, %{response | body: error}, client} 95 + 96 + {:error, %Error{} = error} -> 97 + {:error, error, client} 98 + end 99 + 100 + {:ok, %{body: %{"error" => error} = body}, client} -> 101 + {:error, 102 + %Error{ 103 + error: error, 104 + message: Map.get(body, "message"), 105 + error_struct: nil 106 + }, client} 107 + 81 108 {:ok, _, _} = ok -> 82 109 ok 83 110 ··· 148 175 149 176 output_struct = Module.concat(module, Output) 150 177 output_exists = Code.ensure_loaded?(output_struct) 178 + coerce_exists = function_exported?(module, :coerce_error, 1) 151 179 152 180 case client.__struct__.post(client, module.id(), opts) do 153 181 {:ok, %{status: 200} = response, client} -> ··· 162 190 else 163 191 {:ok, response, client} 164 192 end 193 + 194 + {:ok, %{body: %{"error" => _}} = response, client} when coerce_exists -> 195 + case module.coerce_error(response.body) do 196 + {:ok, %Error{} = error} -> 197 + {:ok, %{response | body: error}, client} 198 + 199 + {:error, %Error{} = error} -> 200 + {:error, error, client} 201 + end 202 + 203 + {:ok, %{body: %{"error" => error} = body}, client} -> 204 + {:error, 205 + %Error{ 206 + error: error, 207 + message: Map.get(body, "message"), 208 + error_struct: nil 209 + }, client} 165 210 166 211 {:ok, _, _} = ok -> 167 212 ok
+41
lib/atex/xrpc/error.ex
··· 1 + defmodule Atex.XRPC.Error do 2 + @moduledoc """ 3 + Represents an XRPC error response. 4 + 5 + When a lexicon defines errors for a query or procedure, the XRPC client will 6 + attempt to coerce error responses into typed error structs. If the error 7 + matches a known lexicon error, `error_struct` will contain the specific struct. 8 + If the error is unknown, `error_struct` will be `nil`. 9 + 10 + ## XRPC Error Response Format 11 + 12 + Per the XRPC spec, error responses have the following JSON structure: 13 + 14 + ```json 15 + { 16 + "error": "ErrorName", 17 + "message": "Human-readable description" 18 + } 19 + ``` 20 + 21 + ## Examples 22 + 23 + %Atex.XRPC.Error{error: "SomethingBroke", message: "Database connection failed"} 24 + 25 + # With a typed error struct 26 + %Atex.XRPC.Error{ 27 + error: "SomethingBroke", 28 + message: "Database connection failed", 29 + error_struct: %Com.Example.DoThing.Errors.SomethingBroke{message: "Database connection failed"} 30 + } 31 + """ 32 + 33 + @enforce_keys [:error] 34 + defstruct [:error, :message, :error_struct] 35 + 36 + @type t :: %__MODULE__{ 37 + error: String.t(), 38 + message: String.t() | nil, 39 + error_struct: module() | nil 40 + } 41 + end
+134
test/atex/lexicon_test.exs
··· 180 180 assert {:error, :no_matching_type} = Lexicon.Test.GetUnion.Output.from_json(%{}) 181 181 end 182 182 end 183 + 184 + # --------------------------------------------------------------------------- 185 + # Tests: query with errors 186 + # --------------------------------------------------------------------------- 187 + 188 + describe "query with defined errors" do 189 + test "generates an Errors submodule" do 190 + assert Code.ensure_loaded?(Lexicon.Test.DoThing.Errors) 191 + end 192 + 193 + test "Errors submodule has error structs" do 194 + assert Code.ensure_loaded?(Lexicon.Test.DoThing.Errors.SomethingBroke) 195 + assert Code.ensure_loaded?(Lexicon.Test.DoThing.Errors.DoesNotCompute) 196 + end 197 + 198 + test "error structs have from_json/1" do 199 + assert function_exported?(Lexicon.Test.DoThing.Errors.SomethingBroke, :from_json, 1) 200 + assert function_exported?(Lexicon.Test.DoThing.Errors.DoesNotCompute, :from_json, 1) 201 + end 202 + 203 + test "error structs have error_name/0" do 204 + assert Lexicon.Test.DoThing.Errors.SomethingBroke.error_name() == "SomethingBroke" 205 + assert Lexicon.Test.DoThing.Errors.DoesNotCompute.error_name() == "DoesNotCompute" 206 + end 207 + 208 + test "error from_json matches error with message" do 209 + assert {:ok, error} = 210 + Lexicon.Test.DoThing.Errors.SomethingBroke.from_json(%{ 211 + "error" => "SomethingBroke", 212 + "message" => "Database connection failed" 213 + }) 214 + 215 + assert error.message == "Database connection failed" 216 + end 217 + 218 + test "error from_json matches error without message" do 219 + assert {:ok, error} = 220 + Lexicon.Test.DoThing.Errors.SomethingBroke.from_json(%{ 221 + "error" => "SomethingBroke" 222 + }) 223 + 224 + assert error.message == nil 225 + end 226 + 227 + test "error from_json returns error for non-matching error name" do 228 + assert {:error, :not_this_error} = 229 + Lexicon.Test.DoThing.Errors.SomethingBroke.from_json(%{ 230 + "error" => "DoesNotCompute" 231 + }) 232 + end 233 + 234 + test "root module exports coerce_error/1" do 235 + assert function_exported?(Lexicon.Test.DoThing, :coerce_error, 1) 236 + end 237 + 238 + test "coerce_error/1 matches a known error" do 239 + assert {:ok, %Atex.XRPC.Error{error: "SomethingBroke", error_struct: error_struct}} = 240 + Lexicon.Test.DoThing.coerce_error(%{ 241 + "error" => "SomethingBroke", 242 + "message" => "It broke" 243 + }) 244 + 245 + assert error_struct.__struct__.error_name() == "SomethingBroke" 246 + assert error_struct.message == "It broke" 247 + end 248 + 249 + test "coerce_error/1 returns error for unknown error" do 250 + assert {:error, %Atex.XRPC.Error{error: "UnknownError", error_struct: nil}} = 251 + Lexicon.Test.DoThing.coerce_error(%{ 252 + "error" => "UnknownError", 253 + "message" => "What happened?" 254 + }) 255 + end 256 + 257 + test "coerce_error/1 returns error for non-error body" do 258 + assert {:error, :not_an_error} = Lexicon.Test.DoThing.coerce_error(%{"data" => "value"}) 259 + end 260 + end 261 + 262 + # --------------------------------------------------------------------------- 263 + # Tests: procedure with errors 264 + # --------------------------------------------------------------------------- 265 + 266 + describe "procedure with defined errors" do 267 + test "generates an Errors submodule" do 268 + assert Code.ensure_loaded?(Lexicon.Test.DoOtherThing.Errors) 269 + end 270 + 271 + test "error structs have from_json/1" do 272 + Code.ensure_loaded!(Lexicon.Test.DoOtherThing) 273 + assert function_exported?(Lexicon.Test.DoOtherThing.Errors.ValidationFailed, :from_json, 1) 274 + end 275 + 276 + test "root module exports coerce_error/1" do 277 + Code.ensure_loaded!(Lexicon.Test.DoOtherThing) 278 + assert function_exported?(Lexicon.Test.DoOtherThing, :coerce_error, 1) 279 + end 280 + 281 + test "coerce_error/1 matches a known error" do 282 + assert {:ok, %Atex.XRPC.Error{error: "ValidationFailed", error_struct: _}} = 283 + Lexicon.Test.DoOtherThing.coerce_error(%{ 284 + "error" => "ValidationFailed", 285 + "message" => "Invalid data" 286 + }) 287 + end 288 + end 289 + 290 + # --------------------------------------------------------------------------- 291 + # Tests: error struct JSON encoding 292 + # --------------------------------------------------------------------------- 293 + 294 + describe "error struct JSON encoding" do 295 + test "encodes with message" do 296 + {:ok, error_struct} = 297 + Lexicon.Test.DoThing.Errors.SomethingBroke.from_json(%{ 298 + "error" => "SomethingBroke", 299 + "message" => "Test message" 300 + }) 301 + 302 + json = Jason.encode!(error_struct) 303 + assert json =~ ~s("error":"SomethingBroke") 304 + assert json =~ ~s("message":"Test message") 305 + end 306 + 307 + test "encodes without message" do 308 + {:ok, error_struct} = 309 + Lexicon.Test.DoThing.Errors.SomethingBroke.from_json(%{ 310 + "error" => "SomethingBroke" 311 + }) 312 + 313 + json = Jason.encode!(error_struct) 314 + assert json == ~s({"error":"SomethingBroke"}) 315 + end 316 + end 183 317 end
+58
test/support/lexicon_fixtures.ex
··· 235 235 } 236 236 }) 237 237 end 238 + 239 + # Query with defined errors. 240 + # NSID "lexicon.test.doThing" -> Lexicon.Test.DoThing 241 + defmodule Lexicon.Test.DoThing do 242 + @moduledoc false 243 + use Atex.Lexicon 244 + 245 + deflexicon(%{ 246 + "lexicon" => 1, 247 + "id" => "lexicon.test.doThing", 248 + "defs" => %{ 249 + "main" => %{ 250 + "type" => "query", 251 + "parameters" => %{ 252 + "type" => "params", 253 + "required" => ["arg"], 254 + "properties" => %{ 255 + "arg" => %{"type" => "string"} 256 + } 257 + }, 258 + "errors" => [ 259 + %{"name" => "SomethingBroke", "description" => "Something went wrong"}, 260 + %{"name" => "DoesNotCompute", "description" => "Invalid input provided"} 261 + ] 262 + } 263 + } 264 + }) 265 + end 266 + 267 + # Procedure with defined errors. 268 + # NSID "lexicon.test.doOtherThing" -> Lexicon.Test.DoOtherThing 269 + defmodule Lexicon.Test.DoOtherThing do 270 + @moduledoc false 271 + use Atex.Lexicon 272 + 273 + deflexicon(%{ 274 + "lexicon" => 1, 275 + "id" => "lexicon.test.doOtherThing", 276 + "defs" => %{ 277 + "main" => %{ 278 + "type" => "procedure", 279 + "input" => %{ 280 + "encoding" => "application/json", 281 + "schema" => %{ 282 + "type" => "object", 283 + "required" => ["data"], 284 + "properties" => %{ 285 + "data" => %{"type" => "string"} 286 + } 287 + } 288 + }, 289 + "errors" => [ 290 + %{"name" => "ValidationFailed"} 291 + ] 292 + } 293 + } 294 + }) 295 + end