Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement overridable global ID translator #93

Merged
merged 15 commits into from
Apr 12, 2018
108 changes: 83 additions & 25 deletions lib/absinthe/relay/node.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ defmodule Absinthe.Relay.Node do

This will create an object type, `:person`, as you might expect. An `:id`
field is created for you automatically, and this field generates a global ID;
a Base64 string that's built using the object type name and the raw, internal
identifier. All of this is handled for you automatically by prefixing your
object type definition with `"node "`.
an opaque string that's built using a global ID translator (by default a
Base64 implementation). All of this is handled for you automatically by
prefixing your object type definition with `"node "`.

The raw, internal value is retrieved using `default_id_fetcher/2` which just
pattern matches an `:id` field from the resolved object. If you need to
Expand All @@ -101,6 +101,9 @@ defmodule Absinthe.Relay.Node do
end
```

For instructions on how to change the underlying method of decoding/encoding
a global ID, see `Absinthe.Relay.Node.IDTranslator`.

## Macros

For more details on node-related macros, see
Expand All @@ -110,6 +113,8 @@ defmodule Absinthe.Relay.Node do

require Logger

@type global_id_t :: binary
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make this @type global_id :: binary (we're moving away from the _t suffix in our types to better match wider community conventions).


# Middleware to handle a global id
# parses the global ID before invoking it
@doc false
Expand All @@ -128,6 +133,9 @@ defmodule Absinthe.Relay.Node do
@doc """
Parse a global ID, given a schema.

To change the underlying method of decoding a global ID,
see `Absinthe.Relay.Node.IDTranslator`.

## Examples

For `nil`, pass-through:
Expand Down Expand Up @@ -165,22 +173,21 @@ defmodule Absinthe.Relay.Node do
{:error, "Type `Item' is not a valid node type"}
```
"""
@spec from_global_id(nil, atom) :: {:ok, nil}
@spec from_global_id(binary, atom) :: {:ok, %{type: atom, id: binary}} | {:error, binary}
@spec from_global_id(nil, Absinthe.Schema.t) :: {:ok, nil}
@spec from_global_id(global_id_t, Absinthe.Schema.t) :: {:ok, %{type: atom, id: binary}} | {:error, binary}
def from_global_id(nil, _schema) do
{:ok, nil}
end
def from_global_id(global_id, schema) do
case Base.decode64(global_id) do
{:ok, decoded} ->
String.split(decoded, ":", parts: 2)
|> do_from_global_id(decoded, schema)
:error ->
{:error, "Could not decode ID value `#{global_id}'"}
case translate_global_id(schema, :from_global_id, [global_id]) do
{:ok, type_name, id} ->
do_from_global_id({type_name, id}, schema)
{:error, err} ->
{:error, err}
end
end

defp do_from_global_id([type_name, id], _, schema) when byte_size(id) > 0 and byte_size(type_name) > 0 do
defp do_from_global_id({type_name, id}, schema) do
case schema.__absinthe_type__(type_name) do
nil ->
{:error, "Unknown type `#{type_name}'"}
Expand All @@ -192,12 +199,25 @@ defmodule Absinthe.Relay.Node do
end
end
end
defp do_from_global_id(_, decoded, _schema) do
{:error, "Could not extract value from decoded ID `#{inspect decoded}'"}

@doc """
Similar to `from_global_id/2` but raises `RuntimeError` if fails to decode global ID.
Copy link
Contributor Author

@avitex avitex Nov 21, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bruce Not sure if RuntimeError is correct in this context (same with to_global_id!/2). What do you think?

"""
@spec from_global_id!(global_id_t | nil, Absinthe.Schema.t) :: %{type: atom, id: binary} | nil
def from_global_id!(global_id, schema) do
case from_global_id(global_id, schema) do
{:ok, result} ->
result
{:error, err} ->
raise RuntimeError, message: err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make a dedicated, specific error type for this in case anyone wants to catch it.

end
end

@doc """
Generate a global ID given a node type name and an internal (non-global) ID
Generate a global ID given a node type name and an internal (non-global) ID given a schema.

To change the underlying method of encoding a global ID,
see `Absinthe.Relay.Node.IDTranslator`.

## Examples

Expand All @@ -210,19 +230,57 @@ defmodule Absinthe.Relay.Node do
"No source non-global ID value given"
```
"""
@spec to_global_id(atom | binary, integer | binary | nil) :: binary | nil
def to_global_id(_node_type, nil) do
nil
@spec to_global_id(atom | binary, integer | binary | nil, Absinthe.Schema.t | nil) :: {:ok, global_id_t | nil} | {:error, binary}
def to_global_id(node_type, source_id, schema \\ nil)
def to_global_id(_node_type, nil, _schema) do
{:ok, nil}
end
def to_global_id(node_type, source_id) when is_binary(node_type) do
"#{node_type}:#{source_id}" |> Base.encode64
def to_global_id(node_type, source_id, schema) when is_binary(node_type) do
translate_global_id(schema, :to_global_id, [node_type, source_id])
end
def to_global_id(node_type, source_id, schema) when is_atom(node_type) do
def to_global_id(node_type, source_id, schema) when is_atom(node_type) and not is_nil(schema) do
case Absinthe.Schema.lookup_type(schema, node_type) do
nil ->
nil
{:ok, nil}
type ->
to_global_id(type.name, source_id)
to_global_id(type.name, source_id, schema)
end
end

@doc """
Similar to `to_global_id/3` but raises `RuntimeError` if fails to encode global ID.
"""
@spec to_global_id!(atom | binary, integer | binary | nil, Absinthe.Schema.t | nil) :: global_id_t | nil
def to_global_id!(node_type, source_id, schema \\ nil) do
case to_global_id(node_type, source_id, schema) do
{:ok, global_id} ->
global_id
{:error, err} ->
raise RuntimeError, message: err
end
end

defp translate_global_id(nil, direction, args) do
apply(Absinthe.Relay.Node.IDTranslator.Default, direction, args ++ [nil])
end
defp translate_global_id(schema, direction, args) do
(global_id_translator(:env, schema) || global_id_translator(:schema, schema) || Absinthe.Relay.Node.IDTranslator.Default)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure that the environment setting should be the fallback for the explicit schema (use) setting, not the other way around.

|> apply(direction, args ++ [schema])
end

@non_relay_schema_error "Non relay schema provided"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capitalize "Relay"


defp global_id_translator(:env, schema) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be nice to have this just be global_id_translator/1, and have the env vs schema vs default be an implementation concern of the function vs using a "magic atom" argument. Could you also make it def instead of defp for testing/debugging purposes?

With this in place, translate_global_id/3 could be simplified quite a bit.

Absinthe.Relay
|> Application.get_env(schema, [])
|> Keyword.get(:global_id_translator, nil)
end
defp global_id_translator(:schema, schema) do
case Keyword.get(schema.__info__(:functions), :__absinthe_relay_global_id_translator__) do
0 ->
apply(schema, :__absinthe_relay_global_id_translator__, [])
nil ->
raise ArgumentError, message: @non_relay_schema_error
end
end

Expand All @@ -241,7 +299,7 @@ defmodule Absinthe.Relay.Node do
nil ->
report_fetch_id_error(type.name, info.source)
internal_id ->
{:ok, to_global_id(type.name, internal_id)}
to_global_id(type.name, internal_id, info.schema)
end
end
end
Expand All @@ -251,7 +309,7 @@ defmodule Absinthe.Relay.Node do
nil ->
report_fetch_id_error(type_name, info.source)
internal_id ->
{:ok, to_global_id(type_name, internal_id)}
to_global_id(type_name, internal_id, info.schema)
end
end
end
Expand Down
38 changes: 38 additions & 0 deletions lib/absinthe/relay/node/id_translator.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
defmodule Absinthe.Relay.Node.IDTranslator do
@moduledoc """
An ID translator handles encoding and decoding a global ID
used in a relay node.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capitalize "Relay"


This module provides the behaviour for implementing an ID Translator.
Example use cases of this module would be a translator that encypts the
global ID or perhaps use a different base encoding.

## Example

A basic example that encodes the global ID by joining the `type_name` and
`source_id` with `":"`.

```
defmodule MyApp.Absinthe.IDTranslator do
@behaviour Absinthe.Relay.Node.IDTranslator

def to_global_id(type_name, source_id, _schema) do
{:ok, "\#{type_name}:\#{source_id}"}
end

def from_global_id(global_id, _schema) do
case String.split(global_id, ":", parts: 2) do
[type_name, source_id] ->
{:ok, type_name, source_id}
_ ->
{:error, "Could not extract value from ID `\#{inspect global_id}`"}
end
end
end
```
"""

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Callback docs?

@callback to_global_id(binary, binary | integer, Absinthe.Schema.t) :: {:ok, Absinthe.Relay.Node.global_id_t} | {:error, binary}
@callback from_global_id(Absinthe.Relay.Node.global_id_t, Absinthe.Schema.t | nil) :: {:ok, binary, binary} | {:error, binary}

end
25 changes: 25 additions & 0 deletions lib/absinthe/relay/node/id_translator/default.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule Absinthe.Relay.Node.IDTranslator.Default do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's be more explicit with this and call it Absinthe.Relay.Node.IDTranslator.Base64—the fact it's the default doesn't tell you what it does when you see it referenced in code/configuration.

@behaviour Absinthe.Relay.Node.IDTranslator

@moduledoc """
A basic implementation of `Absinthe.Relay.Node.IDTranslator` using Base64 encoding.
"""

def to_global_id(type_name, source_id, _schema) do
{:ok, Base.encode64("#{type_name}:#{source_id}")}
end

def from_global_id(global_id, _schema) do
case Base.decode64(global_id) do
{:ok, decoded} ->
case String.split(decoded, ":", parts: 2) do
[type_name, source_id] when byte_size(type_name) > 0 and byte_size(source_id) > 0 ->
{:ok, type_name, source_id}
_ ->
{:error, "Could not extract value from decoded ID `#{inspect decoded}`"}
end
:error ->
{:error, "Could not decode ID value `#{global_id}'"}
end
end
end
22 changes: 19 additions & 3 deletions lib/absinthe/relay/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,26 @@ defmodule Absinthe.Relay.Schema do
See `Absinthe.Relay`.
"""

defmacro __using__(opts) do
defmacro __using__ do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed.

use Absinthe.Relay.Schema won't call this; use always calls __using__/1, even if no options are provided.

Reference: https://hexdocs.pm/elixir/Kernel.html#use/2

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Learn something new every day, never knew this.

do_using([], [])
end
defmacro __using__(flavor) when is_atom(flavor) do
do_using(flavor, [])
end
defmacro __using__(opts) when is_list(opts) do
opts
|> Keyword.get(:flavor, [])
|> do_using(opts)
end

defp do_using(flavor, opts) do
quote do
use Absinthe.Relay.Schema.Notation, unquote(opts)
use Absinthe.Relay.Schema.Notation, unquote(flavor)
import_types Absinthe.Relay.Connection.Types

def __absinthe_relay_global_id_translator__ do
Keyword.get(unquote(opts), :global_id_translator, nil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third argument not necessary. Keyword.get/2 returns nil when not found.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is an old habit I have in being a bit too explicit

end
end
end
end
end
3 changes: 0 additions & 3 deletions lib/absinthe/relay/schema/notation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ defmodule Absinthe.Relay.Schema.Notation do
end

@spec notations(flavor) :: Macro.t



defp notations(flavor) do
mutation_notation = Absinthe.Relay.Mutation.Notation |> flavored(flavor)
quote do
Expand Down
48 changes: 34 additions & 14 deletions test/lib/absinthe/relay/node/parse_ids_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,29 @@ defmodule Absinthe.Relay.Node.ParseIDsTest do
defstruct [:id, :name]
end

defmodule CustomIDTranslator do
@behaviour Absinthe.Relay.Node.IDTranslator

def to_global_id(type_name, source_id, _schema) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we set @impl true for the callbacks? I'd like to encourage people use it (or at least be aware it's available), and tests are commonly where people are looking for examples.

{:ok, "#{type_name}:#{source_id}"}
end

def from_global_id(global_id, _schema) do
case String.split(global_id, ":", parts: 2) do
[type_name, source_id] ->
{:ok, type_name, source_id}
_ ->
{:error, "Could not extract value from ID `#{inspect global_id}`"}
end
end
end

defmodule Schema do
use Absinthe.Schema
use Absinthe.Relay.Schema, :classic
use Absinthe.Relay.Schema, [
flavor: :classic,
global_id_translator: CustomIDTranslator,
]

alias Absinthe.Relay.Node.ParseIDsTest.Foo
alias Absinthe.Relay.Node.ParseIDsTest.Parent
Expand Down Expand Up @@ -189,8 +209,8 @@ defmodule Absinthe.Relay.Node.ParseIDsTest do

end

@foo1_id Base.encode64("Foo:1")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have test cases using the Base64 translator as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And something to verify it's set as the default.

Copy link
Contributor Author

@avitex avitex Nov 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, though I assume Absinthe.Relay.NodeTest will be enough, as it tests with Base64 encoding.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once #97 is fixed, I can refactor these tests a bit further and possibly pull the IDTranslator related tests to its own file.

@foo2_id Base.encode64("Foo:2")
@foo1_id "Foo:1"
@foo2_id "Foo:2"

describe "parses one id" do

Expand Down Expand Up @@ -293,9 +313,9 @@ defmodule Absinthe.Relay.Node.ParseIDsTest do

describe "parsing nested ids" do
test "works with non-null values" do
encoded_parent_id = Base.encode64("Parent:1")
encoded_child1_id = Base.encode64("Child:1")
encoded_child2_id = Base.encode64("Child:1")
encoded_parent_id = "Parent:1"
encoded_child1_id = "Child:1"
encoded_child2_id = "Child:1"
result =
"""
mutation Foobar {
Expand Down Expand Up @@ -329,8 +349,8 @@ defmodule Absinthe.Relay.Node.ParseIDsTest do
assert {:ok, %{data: %{"updateParent" => expected_parent_data}}} == result
end
test "works with null leaf values" do
encoded_parent_id = Base.encode64("Parent:1")
encoded_child1_id = Base.encode64("Child:1")
encoded_parent_id = "Parent:1"
encoded_child1_id = "Child:1"
result =
"""
mutation Foobar {
Expand Down Expand Up @@ -365,8 +385,8 @@ defmodule Absinthe.Relay.Node.ParseIDsTest do
end

test "parses incorrect nested ids" do
encoded_parent_id = Base.encode64("Parent:1")
incorrect_id = Node.to_global_id(:other_foo, 1, Schema)
encoded_parent_id = Node.to_global_id!("Parent", 1, Schema)
incorrect_id = Node.to_global_id!(:other_foo, 1, Schema)
mutation =
"""
mutation Foobar {
Expand Down Expand Up @@ -398,7 +418,7 @@ defmodule Absinthe.Relay.Node.ParseIDsTest do
result =
"""
{
foo(fooId: "#{Node.to_global_id(:other_foo, 1, Schema)}") {
foo(fooId: "#{Node.to_global_id!(:other_foo, 1, Schema)}") {
id
name
}
Expand All @@ -416,9 +436,9 @@ defmodule Absinthe.Relay.Node.ParseIDsTest do
end

test "parses nested ids with local middleware" do
encoded_parent_id = Base.encode64("Parent:1")
encoded_child1_id = Base.encode64("Child:1")
encoded_child2_id = Base.encode64("Child:1")
encoded_parent_id = "Parent:1"
encoded_child1_id = "Child:1"
encoded_child2_id = "Child:1"
result =
"""
mutation FoobarLocal {
Expand Down
Loading