diff --git a/lib/github/plugin/typed_decoder.ex b/lib/github/plugin/typed_decoder.ex index 8b16ffc..34712b6 100644 --- a/lib/github/plugin/typed_decoder.ex +++ b/lib/github/plugin/typed_decoder.ex @@ -76,7 +76,15 @@ defmodule GitHub.Plugin.TypedDecoder do defp do_decode(value, [type]), do: Enum.map(value, &do_decode(&1, type)) defp do_decode(%{} = value, {module, type}) do - base = if function_exported?(module, :__struct__, 0), do: struct(module), else: %{} + # `function_exported?/3` returns false for a module that has not been loaded yet, so under + # interactive code loading the first response decoded for any given schema would fall back to + # a bare map. (The `module.__fields__/1` call below is what loads the module, meaning every + # subsequent decode returns the struct — an intermittent failure for callers matching on it.) + base = + if Code.ensure_loaded?(module) and function_exported?(module, :__struct__, 0), + do: struct(module), + else: %{} + fields = module.__fields__(type) for {field_name, field_type} <- fields, reduce: base do diff --git a/test/github/plugin/typed_decoder_test.exs b/test/github/plugin/typed_decoder_test.exs new file mode 100644 index 0000000..501d2bc --- /dev/null +++ b/test/github/plugin/typed_decoder_test.exs @@ -0,0 +1,40 @@ +defmodule GitHub.Plugin.TypedDecoderTest do + use ExUnit.Case + + alias GitHub.Plugin.TypedDecoder + + # Stands in for a type whose module defines `__fields__/1` without a struct. + defmodule StructlessSchema do + def __fields__(:t), do: [name: {:string, :generic}] + end + + describe "decode/2" do + test "decodes a struct when the schema module is loaded" do + Code.ensure_loaded!(GitHub.Installation.Token) + + assert %GitHub.Installation.Token{token: "ghs_abc"} = + TypedDecoder.decode(%{"token" => "ghs_abc"}, {GitHub.Installation.Token, :t}) + end + + test "decodes a struct when the schema module has not been loaded yet" do + unload(GitHub.Installation.Token) + refute :erlang.module_loaded(GitHub.Installation.Token) + + assert %GitHub.Installation.Token{token: "ghs_abc"} = + TypedDecoder.decode(%{"token" => "ghs_abc"}, {GitHub.Installation.Token, :t}) + end + + test "decodes a bare map when the module has no struct" do + assert TypedDecoder.decode(%{"name" => "test"}, {StructlessSchema, :t}) == %{name: "test"} + end + end + + # Returns the module to the state it has on a cold VM, before anything has + # referenced it. Reloaded afterwards so the rest of the suite is unaffected. + defp unload(module) do + on_exit(fn -> Code.ensure_loaded(module) end) + + :code.purge(module) + :code.delete(module) + end +end