Skip to content

Commit

Permalink
V1.3 (#54)
Browse files Browse the repository at this point in the history
* updates for 1.3

* version bump

* correct version

* mix.lock

* [v1.3] Flexible parsing_node_ids (#50)

* Better errors, multiple types

- Return error on parse failure rather than a thrown `ArgumentError`.
- Support checking against multiple types.

*From https://elixir-lang.slack.com/archives/absinthe-graphql/p1480927370000208*
> I've had a need for checking the node ID type against many rather than just one (in the case of interfaces) so I've played around with `parsing_node_ids/2` to accommodate the requirement.  It's a non-breaking change and fixes an issue where an error should be returned rather than a thrown error.
> The only concern you may have is that it returns `%{ type, id }` or `id` depending on `expected_type(s)`, mainly to prevent this being a breaking change, and also that it makes sense.

* Swap results

Silly me

* Expand tests related to parsing_node_ids with a test proof that the current implementation is incapable of handling a situation where the first node id in a set of multiple node id arguments fails to parse, resulting in a Map.get exception.

* Handle multiple errors, node types

* Update to latest absinthe v1.3 beta

* Add Absinthe.Relay.Node.ParseIDs middleware (#51)

* Prepare for v1.3.0-beta.1 release.
  • Loading branch information
benwilson512 committed Apr 13, 2017
1 parent 45d6bc2 commit 4f2c897
Show file tree
Hide file tree
Showing 15 changed files with 462 additions and 75 deletions.
1 change: 1 addition & 0 deletions .tool-versions
@@ -0,0 +1 @@
elixir 1.4.2
2 changes: 1 addition & 1 deletion .travis.yml
Expand Up @@ -5,5 +5,5 @@ notifications:
- bruce.williams@cargosense.com
- ben.wilson@cargosense.com
otp_release:
- 18.0
- 19.2
script: "MIX_ENV=test mix local.hex --force && MIX_ENV=test mix do deps.get, test"
11 changes: 10 additions & 1 deletion CHANGELOG.md
@@ -1,3 +1,12 @@
# CHANGELOG

Initial version.
## 1.3.0
### Status: Beta

- Enhancement: Added `Absinthe.Relay.Node.ParseIDs`. Use it instead of
`Absinthe.Relay.Helpers.parsing_node_ids/2`, which will be removed in a future
release.
- Enhancement: Allow multiple possible node types when parsing node IDs.
(Thanks, @avitex.)
- Bug Fix: Handle errors when parsing multiple arguments for node IDs more
gracefully. (Thanks to @avitex and @dpehrson.)
31 changes: 17 additions & 14 deletions lib/absinthe/relay/mutation.ex
Expand Up @@ -83,22 +83,25 @@ defmodule Absinthe.Relay.Mutation do
@doc false
# System resolver to extract values from the input and return the
# client mutation ID as part of the response.
def resolve_with_input(designer_resolver) do
fn
%{input: %{client_mutation_id: mut_id} = input}, info ->
case Absinthe.Resolution.call(designer_resolver, input, info) do
{flag, value} when is_map(value) ->
{flag, Map.put(value, :client_mutation_id, mut_id)}
other ->
# On your own!
other
end
_args, info ->
Absinthe.Resolution.call(designer_resolver, %{}, info)
def call(%{state: :unresolved} = res, _) do
case res.arguments do
%{input: %{client_mutation_id: mut_id} = input} ->
%{res |
arguments: input,
private: Map.put(res.private, :__client_mutation_id, mut_id),
middleware: res.middleware ++ [__MODULE__]
}
res ->
res
end
end
def resolve_with_input(_, info, designer_resolver) do
Absinthe.Resolution.call(designer_resolver, %{}, info)
def call(%{state: :resolved, value: value} = res, _) when is_map(value) do
mut_id = res.private[:__client_mutation_id]

%{res | value: Map.put(value, :client_mutation_id, mut_id)}
end
def call(res, _) do
res
end

end
14 changes: 13 additions & 1 deletion lib/absinthe/relay/mutation/notation.ex
Expand Up @@ -34,12 +34,22 @@ defmodule Absinthe.Relay.Mutation.Notation do
@doc false
# Record the mutation field
def record_field!(env, field_ident, attrs, block) do
{maybe_resolve_function, attrs} = case Keyword.pop(attrs, :resolve) do
{nil, attrs} ->
{[], attrs}
{func_ast, attrs} ->
ast = quote do
resolve unquote(func_ast)
end
{ast, attrs}
end
Notation.record_field!(
env,
field_ident,
Keyword.put(attrs, :type, ident(field_ident, :payload)),
[
field_body(field_ident),
maybe_resolve_function,
block,
finalize()
]
Expand All @@ -50,8 +60,10 @@ defmodule Absinthe.Relay.Mutation.Notation do
input_type_identifier = ident(field_ident, :input)
quote do
arg :input, non_null(unquote(input_type_identifier))

middleware Absinthe.Relay.Mutation

private Absinthe.Relay, :mutation_field_identifier, unquote(field_ident)
private Absinthe, :resolve, &Absinthe.Relay.Mutation.resolve_with_input/1
end
end

Expand Down
22 changes: 10 additions & 12 deletions lib/absinthe/relay/node.ex
Expand Up @@ -108,22 +108,20 @@ defmodule Absinthe.Relay.Node do
"""

# Build a wrapper around a resolve function that
# Middleware to handle a global id
# parses the global ID before invoking it
@doc false
def resolve_with_global_id(designer_resolver) do
fn
%{id: global_id}, info ->
case Absinthe.Relay.Node.from_global_id(global_id, info.schema) do
{:ok, result} ->
Absinthe.Resolution.call(designer_resolver, result, info)
other ->
other
end
_, info ->
Absinthe.Resolution.call(designer_resolver, %{}, info)
def resolve_with_global_id(%{state: :unresolved} = res, _) do
with %{id: global_id} <- res.arguments,
{:ok, result} <- Absinthe.Relay.Node.from_global_id(global_id, res.schema) do
%{res | arguments: result}
else
_ -> res
end
end
def resolve_with_global_id(res) do
res
end

@doc """
Parse a global ID, given a schema.
Expand Down
60 changes: 34 additions & 26 deletions lib/absinthe/relay/node/helpers.ex
@@ -1,42 +1,50 @@
defmodule Absinthe.Relay.Node.Helpers do

alias Absinthe.Relay.Node
@moduledoc """
Useful schema helper functions for node IDs.
"""

@doc """
Wrap a resolver to parse node (global) ID arguments before it is executed.
Note: This function is deprecated and will be removed in a future release. Use
the `Absinthe.Relay.Node.ParseIDs` middleware instead.
For each argument:
- If a single node type is provided, the node ID in the argument map will
be replaced by the ID specific to your application.
- If multiple node types are provided (as a list), the node ID in the
argument map will be replaced by a map with the node ID specific to your
application as `:id` and the parsed node type as `:type`.
## Examples
Parse a node (global) ID argument `:item_id` (which should be an ID for the
`:item` type)
Parse a node (global) ID argument `:item_id` as an `:item` type. This replaces
the node ID in the argument map (key `:item_id`) with your
application-specific ID. For example, `"123"`.
```
resolve parsing_node_ids(&my_field_resolver/2, item_id: :item)
```
Parse a node (global) ID argument `:interface_id` into one of multiple node
types. This replaces the node ID in the argument map (key `:interface_id`)
with map of the parsed node type and your application-specific ID. For
example, `%{type: :thing, id: "123"}`.
```
resolve parsing_node_ids(&my_field_resolver/2, interface_id: [:item, :thing])
```
"""
def parsing_node_ids(resolver, id_keys) do
def parsing_node_ids(resolver, rules) do
fn args, info ->
args = Enum.reduce(id_keys, args, fn {key, type}, args ->
with {:ok, global_id} <- Map.fetch(args, key),
{:ok, %{id: id, type: ^type}} <- Node.from_global_id(global_id, info.schema) do
{:success, Map.put(args, key, id)}
end
|> case do
{:ok, %{type: bad_type}} ->
# The user provided an ID for a different type of field,
# notify them in a normal GraphQL error response
{:error, "Invalid node type for argument `#{key}`; should be #{type}, was #{bad_type}"}
{:error, msg} ->
# A more serious error, eg, a missing type, notify
# the schema designer with an exception
raise ArgumentError, msg
{:success, args} ->
args
_ ->
args
end
end)
resolver.(args, info)
Absinthe.Relay.Node.ParseIDs.parse(args, rules, info)
|> case do
{:ok, parsed_args} ->
resolver.(parsed_args, info)
error ->
error
end
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/absinthe/relay/node/notation.ex
Expand Up @@ -81,7 +81,7 @@ defmodule Absinthe.Relay.Node.Notation do
@desc "The id of an object."
arg :id, non_null(:id)

private Absinthe, :resolve, &Absinthe.Relay.Node.resolve_with_global_id/1
middleware {Absinthe.Relay.Node, :resolve_with_global_id}
end
end

Expand Down
171 changes: 171 additions & 0 deletions lib/absinthe/relay/node/parse_ids.ex
@@ -0,0 +1,171 @@
defmodule Absinthe.Relay.Node.ParseIDs do
@behaviour Absinthe.Middleware

@moduledoc """
Parse node (global) ID arguments before they are passed to a resolver,
checking the arguments against acceptable types.
For each argument:
- If a single node type is provided, the node ID in the argument map will
be replaced by the ID specific to your application.
- If multiple node types are provided (as a list), the node ID in the
argument map will be replaced by a map with the node ID specific to your
application as `:id` and the parsed node type as `:type`.
## Examples
Parse a node (global) ID argument `:item_id` as an `:item` type. This replaces
the node ID in the argument map (key `:item_id`) with your
application-specific ID. For example, `"123"`.
```
field :item, :item do
arg :item_id, non_null(:id)
middleware Absinthe.Relay.Node.ParseIDs, item_id: :item
resolve &item_resolver/3
end
```
Parse a node (global) ID argument `:interface_id` into one of multiple node
types. This replaces the node ID in the argument map (key `:interface_id`)
with map of the parsed node type and your application-specific ID. For
example, `%{type: :thing, id: "123"}`.
```
field :foo, :foo do
arg :interface_id, non_null(:id)
middleware Absinthe.Relay.Node.ParseIDs, interface_id: [:item, :thing]
resolve &foo_resolver/3
end
```
As with any piece of middleware, this can configured schema-wide using the
`middleware/3` function in your schema. In this example all top level
query fields are made to support node IDs with the associated criteria in
`@node_id_rules`:
```
defmodule MyApp.Schema do
# Schema ...
@node_id_rules %{
item_id: :item,
interface_id: [:item, :thing],
}
def middleware(middleware, _, %Absinthe.Type.Object{identifier: :query}) do
[{Absinthe.Relay.Node.ParseIDs, @node_id_rules} | middleware]
end
def middleware(middleware, _, _) do
middleware
end
end
```
See the documentation for `Absinthe.Middleware` for more details.
"""

alias Absinthe.Relay.Node

@typedoc """
The rules used to parse node ID arguments.
## Examples
Declare `:item_id` as only valid with the `:item` node type:
```
%{
item_id: :item
}
```
Declare `:item_id` be valid as either `:foo` or `:bar` types:
```
%{
item_id: [:foo, :bar]
}
```
Note that using these two different forms will result in different argument
values being passed for `:item_id` (the former, as a `binary`, the latter
as a `map`). See the module documentation for more details.
"""
@type rules :: %{atom => atom | [atom]}

@doc false
@spec call(Absinthe.Resolution.t, rules) :: Absinthe.Resolution.t
def call(resolution, rules) do
case parse(resolution.arguments, rules, resolution) do
{:ok, parsed_args} ->
%{resolution | arguments: parsed_args}
err ->
resolution
|> Absinthe.Resolution.put_result(err)
end
end

@doc false
@spec parse(map, rules, Absinthe.Resolution.t) :: {:ok, map} | {:error, [String.t]}
def parse(args, rules, resolution) do
matching_rules(rules, args)
|> Enum.reduce({%{}, []}, fn {key, expected_type}, {node_id_args, errors} ->
with global_id <- Map.get(args, key),
{:ok, node_id} <- Node.from_global_id(global_id, resolution.schema),
argument_name <- find_argument_name(key, resolution),
{:ok, node_id} <- check_node_id(node_id, expected_type, argument_name) do
{Map.put(node_id_args, key, node_id), errors}
else
{:error, msg} ->
{node_id_args, [msg | errors]}
end
end)
|> case do
{node_id_args, []} ->
{:ok, Map.merge(args, node_id_args)}
{_, errors} ->
{:error, Enum.reverse(errors)}
end
end

@spec matching_rules(rules, map) :: rules
defp matching_rules(rules, args) do
rules
|> Enum.filter(fn {key, _} -> Map.has_key?(args, key) end)
|> Map.new
end

@spec find_argument_name(atom, Absinthe.Resolution.t) :: nil | String.t
defp find_argument_name(identifier, resolution) do
resolution.definition.arguments
|> Enum.find_value(fn
%{schema_node: %{__reference__: %{identifier: ^identifier}}} = arg ->
arg.name
_ ->
false
end)
end

@spec check_node_id(map, atom, String.t) :: {:ok, binary} | {:error, String.t}
@spec check_node_id(map, [atom], String.t) :: {:ok, %{type: atom, id: binary}} | {:error, String.t}
defp check_node_id(%{type: type} = id_map, expected_types, argument_name) when is_list(expected_types) do
case Enum.member?(expected_types, type) do
true ->
{:ok, id_map}
false ->
{:error, ~s<In argument "#{argument_name}": Expected node type in #{inspect(expected_types)}, found #{inspect(type)}.>}
end
end
defp check_node_id(%{type: expected_type, id: id}, expected_type, _) do
{:ok, id}
end
defp check_node_id(%{type: type}, expected_type, argument_name) do
{:error, ~s<In argument "#{argument_name}": Expected node type #{inspect(expected_type)}, found #{inspect(type)}.>}
end

end

0 comments on commit 4f2c897

Please sign in to comment.