├── test ├── test_helper.exs ├── support │ ├── case.ex │ └── star_wars │ │ ├── database.ex │ │ └── schema.ex ├── star_wars │ ├── object_identification_test.exs │ └── connection_test.exs └── lib │ └── absinthe │ └── relay │ ├── mutation │ ├── classic_test.exs │ └── modern_test.exs │ ├── schema_test.exs │ ├── node_test.exs │ ├── pagination_test.exs │ └── node │ └── parse_ids_test.exs ├── .formatter.exs ├── lib └── absinthe │ ├── relay │ ├── node │ │ ├── parse_ids │ │ │ ├── namespace.ex │ │ │ ├── rule.ex │ │ │ └── config.ex │ │ ├── id_translator │ │ │ └── base64.ex │ │ ├── helpers.ex │ │ ├── id_translator.ex │ │ ├── notation.ex │ │ └── parse_ids.ex │ ├── connection │ │ ├── types.ex │ │ └── notation.ex │ ├── schema.ex │ ├── mutation.ex │ ├── schema │ │ ├── phase.ex │ │ └── notation.ex │ ├── mutation │ │ └── notation │ │ │ ├── modern.ex │ │ │ └── classic.ex │ ├── node.ex │ └── connection.ex │ └── relay.ex ├── .gitignore ├── LICENSE.md ├── config └── config.exs ├── CONTRIBUTING.md ├── .github └── workflows │ └── test.yml ├── mix.exs ├── mix.lock ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.configure(exclude: [pending: true]) 2 | ExUnit.start() 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | import_deps: [:absinthe] 5 | ] 6 | -------------------------------------------------------------------------------- /test/support/case.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Case do 2 | defmacro __using__(opts) do 3 | quote do 4 | use ExUnit.Case, unquote(opts) 5 | import ExUnit.Case 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/absinthe/relay/node/parse_ids/namespace.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Node.ParseIDs.Namespace do 2 | alias Absinthe.Relay.Node.ParseIDs.Config 3 | 4 | @enforce_keys [:key] 5 | defstruct [ 6 | :key, 7 | children: [] 8 | ] 9 | 10 | @type t :: %__MODULE__{ 11 | key: atom, 12 | children: [Config.node_t()] 13 | } 14 | end 15 | -------------------------------------------------------------------------------- /lib/absinthe/relay/connection/types.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Connection.Types do 2 | @moduledoc false 3 | 4 | use Absinthe.Schema.Notation 5 | 6 | object :page_info do 7 | @desc "When paginating backwards, are there more items?" 8 | field :has_previous_page, non_null(:boolean) 9 | 10 | @desc "When paginating forwards, are there more items?" 11 | field :has_next_page, non_null(:boolean) 12 | 13 | @desc "When paginating backwards, the cursor to continue." 14 | field :start_cursor, :string 15 | 16 | @desc "When paginating forwards, the cursor to continue." 17 | field :end_cursor, :string 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/absinthe/relay/node/parse_ids/rule.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Node.ParseIDs.Rule do 2 | alias Absinthe.Relay.Node.ParseIDs 3 | 4 | @enforce_keys [:key] 5 | defstruct [ 6 | :key, 7 | expected_types: [], 8 | output_mode: :full, 9 | schema: nil 10 | ] 11 | 12 | @type t :: %__MODULE__{ 13 | key: atom, 14 | expected_types: [atom], 15 | output_mode: :full | :simple 16 | } 17 | 18 | @spec output(t, nil) :: nil 19 | @spec output(t, ParseIDs.result()) :: ParseIDs.full_result() | ParseIDs.simple_result() 20 | def output(_rule, nil), do: nil 21 | def output(%{output_mode: :full}, result), do: result 22 | def output(%{output_mode: :simple}, %{id: id}), do: id 23 | end 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | absinthe_relay-*.tar 24 | 25 | # Temporary files for e.g. tests. 26 | /tmp/ 27 | 28 | # Misc. 29 | .tool-versions 30 | -------------------------------------------------------------------------------- /lib/absinthe/relay/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Schema do 2 | @moduledoc """ 3 | Used to extend a schema with Relay-specific macros and types. 4 | 5 | See `Absinthe.Relay`. 6 | """ 7 | 8 | defmacro __using__(flavor) when is_atom(flavor) do 9 | do_using(flavor, []) 10 | end 11 | 12 | defmacro __using__(opts) when is_list(opts) do 13 | opts 14 | |> Keyword.get(:flavor, []) 15 | |> do_using(opts) 16 | end 17 | 18 | defp do_using(flavor, opts) do 19 | quote do 20 | @pipeline_modifier unquote(__MODULE__) 21 | use Absinthe.Relay.Schema.Notation, unquote(flavor) 22 | import_types Absinthe.Relay.Connection.Types 23 | 24 | def __absinthe_relay_global_id_translator__ do 25 | Keyword.get(unquote(opts), :global_id_translator) 26 | end 27 | end 28 | end 29 | 30 | def pipeline(pipeline) do 31 | pipeline 32 | |> Absinthe.Pipeline.insert_after(Absinthe.Phase.Schema.TypeImports, __MODULE__.Phase) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/absinthe/relay/node/id_translator/base64.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Node.IDTranslator.Base64 do 2 | @behaviour Absinthe.Relay.Node.IDTranslator 3 | 4 | @moduledoc """ 5 | A basic implementation of `Absinthe.Relay.Node.IDTranslator` using Base64 encoding. 6 | """ 7 | 8 | @impl true 9 | def to_global_id(type_name, source_id, _schema) do 10 | {:ok, Base.encode64("#{type_name}:#{source_id}")} 11 | end 12 | 13 | @impl true 14 | def from_global_id(global_id, _schema) do 15 | case Base.decode64(global_id) do 16 | {:ok, decoded} -> 17 | case String.split(decoded, ":", parts: 2) do 18 | [type_name, source_id] when byte_size(type_name) > 0 and byte_size(source_id) > 0 -> 19 | {:ok, type_name, source_id} 20 | 21 | _ -> 22 | {:error, "Could not extract value from decoded ID `#{inspect(decoded)}`"} 23 | end 24 | 25 | :error -> 26 | {:error, "Could not decode ID value `#{global_id}'"} 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Bruce Williams, Ben Wilson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :absinthe_relay, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:absinthe_relay, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/absinthe/relay.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay do 2 | @moduledoc """ 3 | Relay support for Absinthe. 4 | 5 | - Global Identification: See `Absinthe.Relay.Node` 6 | - Connection Model: See `Absinthe.Relay.Connection` 7 | - Mutations: See `Absinthe.Relay.Mutation` 8 | 9 | ## Examples 10 | 11 | Schemas should `use Absinthe.Relay.Schema` and can optionally select 12 | either `:modern` (targeting Relay v1.0+) or `:classic` (targeting Relay < v1.0): 13 | 14 | ```elixir 15 | defmodule Schema do 16 | use Absinthe.Schema 17 | use Absinthe.Relay.Schema, :modern 18 | 19 | # ... 20 | 21 | end 22 | ``` 23 | 24 | For a type module, use `Absinthe.Relay.Schema.Notation` instead: 25 | 26 | ```elixir 27 | defmodule Schema do 28 | use Absinthe.Schema.Notation 29 | use Absinthe.Relay.Schema.Notation, :modern 30 | 31 | # ... 32 | 33 | end 34 | ``` 35 | 36 | If you do not indicate `:modern` or `:classic`---in v1.4 of this 37 | package---the default of `:classic` will be used. A deprecation 38 | notice will be output indicating that this behavior will change in 39 | v1.5 (to `:modern`). 40 | 41 | See `Absinthe.Relay.Node`, `Absinthe.Relay.Connection`, and 42 | `Absinthe.Relay.Mutation` for specific macro information. 43 | """ 44 | end 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for contributing to the project. We'd love to see your 4 | issues and pull requests. 5 | 6 | If you're creating a pull request, please consider these suggestions: 7 | 8 | Fork, then clone the repo: 9 | 10 | git clone git@github.com:your-username/absinthe_relay.git 11 | 12 | Install the dependencies: 13 | 14 | mix deps.get 15 | 16 | Make sure the tests pass: 17 | 18 | mix test 19 | 20 | Make your change. Add tests for your change. Make the tests pass: 21 | 22 | mix test 23 | 24 | Push to your fork (preferably to a non-`master` branch) and 25 | [submit a pull request][pr]. 26 | 27 | [pr]: https://github.com/absinthe-graphql/absinthe_relay/compare/ 28 | 29 | We'll review and answer your pull request as soon as possible. We may suggest 30 | some changes, improvements, or alternatives. Let's work through it together. 31 | 32 | Some things that will increase the chance that your pull request is accepted: 33 | 34 | * Write tests. 35 | * Include `@typedoc`s, `@spec`s, and `@doc`s 36 | * Try to match the style conventions already present (and Elixir conventions, 37 | generally). 38 | * Write a [good commit message][commit]. 39 | 40 | Thanks again for helping! 41 | 42 | [commit]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 43 | -------------------------------------------------------------------------------- /lib/absinthe/relay/node/parse_ids/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Node.ParseIDs.Config do 2 | alias Absinthe.Relay.Node.ParseIDs.{Namespace, Rule} 3 | 4 | defstruct children: [] 5 | 6 | @type node_t :: Namespace.t() | Rule.t() 7 | 8 | @type t :: %__MODULE__{ 9 | children: [node_t] 10 | } 11 | 12 | def parse!(config) when is_map(config) do 13 | parse!(Keyword.new(config)) 14 | end 15 | 16 | def parse!(config) when is_list(config) do 17 | parse!(config, %__MODULE__{}) 18 | end 19 | 20 | defp parse!(config, %{children: _} = node) when is_list(config) do 21 | children = 22 | Enum.map(config, fn 23 | {key, [{_, _} | _] = value} -> 24 | parse!(value, %Namespace{key: key}) 25 | 26 | {key, value} -> 27 | parse!(value, %Rule{key: key}) 28 | 29 | other -> 30 | raise "Could not parse #{__MODULE__} namespace element: #{inspect(other)}" 31 | end) 32 | 33 | %{node | children: children} 34 | end 35 | 36 | defp parse!(value, %Rule{} = node) when is_atom(value) do 37 | %{node | expected_types: [value], output_mode: :simple} 38 | end 39 | 40 | defp parse!(value, %Rule{} = node) when is_list(value) do 41 | %{node | expected_types: value, output_mode: :full} 42 | end 43 | 44 | defp parse!(value, %Rule{}) do 45 | raise "Could not parse #{__MODULE__} rule: #{inspect(value)}" 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | test: 11 | name: Elixir ${{matrix.elixir}} / OTP ${{matrix.otp}} 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | elixir: 17 | - '1.10' 18 | - '1.11' 19 | otp: 20 | - '22' 21 | - '23' 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v2 26 | 27 | - name: Set up Elixir 28 | uses: erlef/setup-elixir@v1 29 | with: 30 | elixir-version: ${{ matrix.elixir }} 31 | otp-version: ${{ matrix.otp }} 32 | 33 | - name: Restore deps cache 34 | uses: actions/cache@v2 35 | with: 36 | path: | 37 | deps 38 | _build 39 | key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}-git-${{ github.sha }} 40 | restore-keys: | 41 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 42 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} 43 | 44 | - name: Install package dependencies 45 | run: mix deps.get 46 | 47 | - name: Check code format 48 | run: mix format --check-formatted 49 | 50 | - name: Compile dependencies 51 | run: mix compile 52 | env: 53 | MIX_ENV: test 54 | 55 | - name: Run unit tests 56 | run: mix test 57 | -------------------------------------------------------------------------------- /lib/absinthe/relay/mutation.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Mutation do 2 | @moduledoc """ 3 | Middleware to support the macros located in: 4 | 5 | - For Relay Modern: `Absinthe.Relay.Mutation.Notation.Modern` 6 | - For Relay Classic: `Absinthe.Relay.Mutation.Notation.Classic` 7 | 8 | Please see those modules for specific instructions. 9 | """ 10 | 11 | @doc false 12 | 13 | # System resolver to extract values from the input and return the 14 | # client mutation ID (the latter for Relay Classic only) as part of the response. 15 | def call(%{state: :unresolved} = res, _) do 16 | case res.arguments do 17 | %{input: %{client_mutation_id: mut_id} = input} -> 18 | %{ 19 | res 20 | | arguments: input, 21 | private: 22 | Map.merge(res.private, %{__client_mutation_id: mut_id, __parse_ids_root: :input}), 23 | middleware: res.middleware ++ [__MODULE__] 24 | } 25 | 26 | %{input: input} -> 27 | %{ 28 | res 29 | | arguments: input, 30 | private: Map.merge(res.private, %{__parse_ids_root: :input}), 31 | middleware: res.middleware ++ [__MODULE__] 32 | } 33 | 34 | _ -> 35 | res 36 | end 37 | end 38 | 39 | def call(%{state: :resolved, value: value} = res, _) when is_map(value) do 40 | mut_id = res.private[:__client_mutation_id] 41 | 42 | %{res | value: Map.put(value, :client_mutation_id, mut_id)} 43 | end 44 | 45 | def call(res, _) do 46 | res 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/absinthe/relay/schema/phase.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Schema.Phase do 2 | use Absinthe.Phase 3 | 4 | alias Absinthe.Blueprint 5 | alias Absinthe.Blueprint.Schema 6 | 7 | def run(blueprint, _) do 8 | {blueprint, _acc} = Blueprint.postwalk(blueprint, [], &collect_types/2) 9 | 10 | {:ok, blueprint} 11 | end 12 | 13 | defp collect_types(%Schema.SchemaDefinition{} = schema, new_types) do 14 | new_types = 15 | Enum.reject(new_types, fn new_type -> 16 | Enum.any?(schema.type_definitions, fn t -> t.identifier == new_type.identifier end) 17 | end) 18 | 19 | schema = 20 | schema 21 | |> Map.update!(:type_definitions, &(new_types ++ &1)) 22 | |> Blueprint.prewalk(&fill_nodes/1) 23 | 24 | {schema, []} 25 | end 26 | 27 | defp collect_types(%{__private__: private} = node, types) do 28 | attrs = private[:absinthe_relay] || [] 29 | 30 | types = 31 | Enum.reduce(attrs, types, fn 32 | {kind, {:fill, style}}, types -> 33 | List.wrap(style.additional_types(kind, node)) ++ types 34 | 35 | _, types -> 36 | types 37 | end) 38 | 39 | {node, types} 40 | end 41 | 42 | defp collect_types(node, acc) do 43 | {node, acc} 44 | end 45 | 46 | defp fill_nodes(%{__private__: private} = node) do 47 | Enum.reduce(private[:absinthe_relay] || [], node, fn 48 | {type, {:fill, style}}, node -> style.fillout(type, node) 49 | _, node -> node 50 | end) 51 | end 52 | 53 | defp fill_nodes(node) do 54 | node 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule AbsintheRelay.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/absinthe-graphql/absinthe_relay" 5 | @version "1.5.1" 6 | 7 | def project do 8 | [ 9 | app: :absinthe_relay, 10 | version: @version, 11 | elixir: "~> 1.10", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | build_embedded: Mix.env() == :prod, 14 | start_permanent: Mix.env() == :prod, 15 | package: package(), 16 | docs: docs(), 17 | deps: deps(), 18 | xref: [ 19 | exclude: [:ecto] 20 | ] 21 | ] 22 | end 23 | 24 | defp package do 25 | [ 26 | description: "Relay framework support for Absinthe", 27 | files: ["lib", "mix.exs", "README*", "CHANGELOG*"], 28 | maintainers: ["Bruce Williams", "Ben Wilson"], 29 | licenses: ["MIT"], 30 | links: %{ 31 | Changelog: "https://hexdocs.pm/absinthe_relay/changelog.html", 32 | GitHub: @source_url 33 | } 34 | ] 35 | end 36 | 37 | defp docs do 38 | [ 39 | extras: ["CHANGELOG.md", "README.md"], 40 | main: "readme", 41 | source_url: @source_url, 42 | source_ref: "v#{@version}", 43 | formatters: ["html"] 44 | ] 45 | end 46 | 47 | def application do 48 | [extra_applications: [:logger]] 49 | end 50 | 51 | # Specifies which paths to compile per environment. 52 | defp elixirc_paths(:test), do: ["lib", "test/support"] 53 | defp elixirc_paths(_), do: ["lib"] 54 | 55 | defp deps do 56 | [ 57 | {:absinthe, "~> 1.5.0 or ~> 1.6.0"}, 58 | {:ecto, "~> 2.0 or ~> 3.0", optional: true}, 59 | {:poison, ">= 0.0.0", only: [:dev, :test]}, 60 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 61 | ] 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/absinthe/relay/node/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Node.Helpers do 2 | @moduledoc """ 3 | Useful schema helper functions for node IDs. 4 | """ 5 | 6 | @doc """ 7 | Wrap a resolver to parse node (global) ID arguments before it is executed. 8 | 9 | Note: This function is deprecated and will be removed in a future release. Use 10 | the `Absinthe.Relay.Node.ParseIDs` middleware instead. 11 | 12 | For each argument: 13 | 14 | - If a single node type is provided, the node ID in the argument map will 15 | be replaced by the ID specific to your application. 16 | - If multiple node types are provided (as a list), the node ID in the 17 | argument map will be replaced by a map with the node ID specific to your 18 | application as `:id` and the parsed node type as `:type`. 19 | 20 | ## Examples 21 | 22 | Parse a node (global) ID argument `:item_id` as an `:item` type. This replaces 23 | the node ID in the argument map (key `:item_id`) with your 24 | application-specific ID. For example, `"123"`. 25 | 26 | ``` 27 | resolve parsing_node_ids(&my_field_resolver/2, item_id: :item) 28 | ``` 29 | 30 | Parse a node (global) ID argument `:interface_id` into one of multiple node 31 | types. This replaces the node ID in the argument map (key `:interface_id`) 32 | with map of the parsed node type and your application-specific ID. For 33 | example, `%{type: :thing, id: "123"}`. 34 | 35 | ``` 36 | resolve parsing_node_ids(&my_field_resolver/2, interface_id: [:item, :thing]) 37 | ``` 38 | """ 39 | def parsing_node_ids(resolver, rules) do 40 | fn args, info -> 41 | Absinthe.Relay.Node.ParseIDs.parse(args, rules, info) 42 | |> case do 43 | {:ok, parsed_args} -> 44 | resolver.(parsed_args, info) 45 | 46 | error -> 47 | error 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/support/star_wars/database.ex: -------------------------------------------------------------------------------- 1 | defmodule StarWars.Database do 2 | @xwing %{ 3 | id: "1", 4 | name: "X-Wing" 5 | } 6 | 7 | @ywing %{ 8 | id: "2", 9 | name: "Y-Wing" 10 | } 11 | 12 | @awing %{ 13 | id: "3", 14 | name: "A-Wing" 15 | } 16 | 17 | # Yeah, technically it's Corellian. But it flew in the service of the rebels, 18 | # so for the purposes of this demo it"s a rebel ship. 19 | @falcon %{ 20 | id: "4", 21 | name: "Millenium Falcon" 22 | } 23 | 24 | @home_one %{ 25 | id: "5", 26 | name: "Home One" 27 | } 28 | 29 | @tie_fighter %{ 30 | id: "6", 31 | name: "TIE Fighter" 32 | } 33 | 34 | @tie_interceptor %{ 35 | id: "7", 36 | name: "TIE Interceptor" 37 | } 38 | 39 | @executor %{ 40 | id: "8", 41 | name: "Executor" 42 | } 43 | 44 | @rebels %{ 45 | id: "1", 46 | name: "Alliance to Restore the Republic", 47 | ships: ["1", "2", "3", "4", "5"] 48 | } 49 | 50 | @empire %{ 51 | id: "2", 52 | name: "Galactic Empire", 53 | ships: ["6", "7", "8"] 54 | } 55 | 56 | @data %{ 57 | faction: %{ 58 | "1" => @rebels, 59 | "2" => @empire 60 | }, 61 | ship: %{ 62 | "1" => @xwing, 63 | "2" => @ywing, 64 | "3" => @awing, 65 | "4" => @falcon, 66 | "5" => @home_one, 67 | "6" => @tie_fighter, 68 | "7" => @tie_interceptor, 69 | "8" => @executor 70 | } 71 | } 72 | 73 | def data, do: @data 74 | 75 | def get(node_type, id) do 76 | case data() |> get_in([node_type, id]) do 77 | nil -> 78 | {:error, "No #{node_type} with ID #{id}"} 79 | 80 | result -> 81 | {:ok, result} 82 | end 83 | end 84 | 85 | def get_factions(names) do 86 | factions = data().factions |> Map.values() 87 | 88 | names 89 | |> Enum.map(fn name -> 90 | factions 91 | |> Enum.find_value(&(&1 == name)) 92 | end) 93 | end 94 | 95 | def get_rebels do 96 | {:ok, @rebels} 97 | end 98 | 99 | def get_empire do 100 | {:ok, @empire} 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/star_wars/object_identification_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StarWars.ObjectIdentificationTest do 2 | use Absinthe.Relay.Case, async: true 3 | 4 | describe "Star Wars object identification" do 5 | test "fetches the ID and name of the rebels" do 6 | """ 7 | query RebelsQuery { 8 | rebels { 9 | id 10 | name 11 | } 12 | } 13 | """ 14 | |> assert_data(%{ 15 | "rebels" => %{"id" => "RmFjdGlvbjox", "name" => "Alliance to Restore the Republic"} 16 | }) 17 | end 18 | 19 | test "fetches the ID and name of the empire" do 20 | """ 21 | query EmpireQuery { 22 | empire { 23 | id 24 | name 25 | } 26 | } 27 | """ 28 | |> assert_data(%{"empire" => %{"id" => "RmFjdGlvbjoy", "name" => "Galactic Empire"}}) 29 | end 30 | 31 | test "refetches the empire" do 32 | """ 33 | query EmpireRefetchQuery { 34 | node(id: "RmFjdGlvbjoy") { 35 | id 36 | ... on Faction { 37 | name 38 | } 39 | } 40 | } 41 | """ 42 | |> assert_data(%{"node" => %{"id" => "RmFjdGlvbjoy", "name" => "Galactic Empire"}}) 43 | end 44 | 45 | test "refetches the empire, with nested redundant Node fragment" do 46 | """ 47 | query EmpireRefetchQueryWithExtraNodeFragment { 48 | node(id: "RmFjdGlvbjoy") { 49 | id 50 | ... on Faction { 51 | ... on Node { 52 | ... on Faction { 53 | name 54 | } 55 | } 56 | } 57 | } 58 | } 59 | """ 60 | |> assert_data(%{"node" => %{"id" => "RmFjdGlvbjoy", "name" => "Galactic Empire"}}) 61 | end 62 | 63 | test "refetches the X-Wing" do 64 | """ 65 | query XWingRefetchQuery { 66 | node(id: "U2hpcDox") { 67 | id 68 | ... on Ship { 69 | name 70 | } 71 | } 72 | } 73 | """ 74 | |> assert_data(%{"node" => %{"id" => "U2hpcDox", "name" => "X-Wing"}}) 75 | end 76 | end 77 | 78 | defp assert_data(query, data) do 79 | assert {:ok, %{data: data}} == Absinthe.run(query, StarWars.Schema) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/absinthe/relay/node/id_translator.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Node.IDTranslator do 2 | @moduledoc """ 3 | An ID translator handles encoding and decoding a global ID 4 | used in a Relay node. 5 | 6 | This module provides the behaviour for implementing an ID Translator. 7 | An example use case of this module would be a translator that encrypts the 8 | global ID. 9 | 10 | To use an ID Translator in your schema there are two methods. 11 | 12 | #### Inline Config 13 | ``` 14 | defmodule MyApp.Schema do 15 | use Absinthe.Schema 16 | use Absinthe.Relay.Schema, [ 17 | flavor: :modern, 18 | global_id_translator: MyApp.Absinthe.IDTranslator 19 | ] 20 | 21 | # ... 22 | 23 | end 24 | ``` 25 | 26 | #### Mix Config 27 | ``` 28 | config Absinthe.Relay, MyApp.Schema, 29 | global_id_translator: MyApp.Absinthe.IDTranslator 30 | ``` 31 | 32 | ## Example ID Translator 33 | 34 | A basic example that encodes the global ID by joining the `type_name` and 35 | `source_id` with `":"`. 36 | 37 | ``` 38 | defmodule MyApp.Absinthe.IDTranslator do 39 | @behaviour Absinthe.Relay.Node.IDTranslator 40 | 41 | def to_global_id(type_name, source_id, _schema) do 42 | {:ok, "\#{type_name}:\#{source_id}"} 43 | end 44 | 45 | def from_global_id(global_id, _schema) do 46 | case String.split(global_id, ":", parts: 2) do 47 | [type_name, source_id] -> 48 | {:ok, type_name, source_id} 49 | _ -> 50 | {:error, "Could not extract value from ID `\#{inspect global_id}`"} 51 | end 52 | end 53 | end 54 | ``` 55 | """ 56 | 57 | @doc """ 58 | Converts a node's type name and ID to a globally unique ID. 59 | 60 | Returns `{:ok, global_id}` on success. 61 | 62 | Returns `{:error, binary}` on failure. 63 | """ 64 | @callback to_global_id( 65 | type_name :: binary, 66 | source_id :: binary | integer, 67 | schema :: Absinthe.Schema.t() 68 | ) :: {:ok, global_id :: Absinthe.Relay.Node.global_id()} | {:error, binary} 69 | 70 | @doc """ 71 | Converts a globally unique ID to a node's type name and ID. 72 | 73 | Returns `{:ok, type_name, source_id}` on success. 74 | 75 | Returns `{:error, binary}` on failure. 76 | """ 77 | @callback from_global_id( 78 | global_id :: Absinthe.Relay.Node.global_id(), 79 | schema :: Absinthe.Schema.t() | nil 80 | ) :: {:ok, type_name :: binary, source_id :: binary} | {:error, binary} 81 | end 82 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "absinthe": {:hex, :absinthe, "1.6.0", "7cb42eebbb9cbf5077541d73c189e205ebe12caf1c78372fc5b9e706fc8ac298", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "99915841495522332b3af8ff10c9cbb51e256b28d9b19c0dfaac5f044b6bfb66"}, 3 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 4 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, 6 | "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, 7 | "ex_doc": {:hex, :ex_doc, "0.24.1", "15673de99154f93ca7f05900e4e4155ced1ee0cd34e0caeee567900a616871a4", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "07972f17bdf7dc7b5bd76ec97b556b26178ed3f056e7ec9288eb7cea7f91cce2"}, 8 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 10 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 11 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 12 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, 13 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 14 | } 15 | -------------------------------------------------------------------------------- /test/support/star_wars/schema.ex: -------------------------------------------------------------------------------- 1 | # Derived from the "Star Wars" Relay example as of this commit: 2 | # https://github.com/facebook/relay/commit/841b169a192394c3650d5264cf95a230f89acb66 3 | # 4 | # This file provided by Facebook is for non-commercial testing and evaluation 5 | # purposes only. Facebook reserves all rights not expressly granted. 6 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 7 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 8 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 9 | # FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 10 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 11 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | # 13 | # 14 | # Using our shorthand to describe type systems, 15 | # the type system for our example will be the following: 16 | # 17 | # interface Node { 18 | # id: ID! 19 | # } 20 | # 21 | # type Faction : Node { 22 | # id: ID! 23 | # name: String 24 | # ships: ShipConnection 25 | # } 26 | # 27 | # type Ship : Node { 28 | # id: ID! 29 | # name: String 30 | # } 31 | # 32 | # type ShipConnection { 33 | # edges: [ShipEdge] 34 | # pageInfo: PageInfo! 35 | # } 36 | # 37 | # type ShipEdge { 38 | # cursor: String! 39 | # node: Ship 40 | # } 41 | # 42 | # type PageInfo { 43 | # hasNextPage: Boolean! 44 | # hasPreviousPage: Boolean! 45 | # startCursor: String 46 | # endCursor: String 47 | # } 48 | # 49 | # type Query { 50 | # rebels: Faction 51 | # empire: Faction 52 | # node(id: ID!): Node 53 | # } 54 | # 55 | # input IntroduceShipInput { 56 | # clientMutationId: string! 57 | # shipName: string! 58 | # factionId: ID! 59 | # } 60 | # 61 | # input IntroduceShipPayload { 62 | # clientMutationId: string! 63 | # ship: Ship 64 | # faction: Faction 65 | # } 66 | # 67 | # type Mutation { 68 | # introduceShip(input IntroduceShipInput!): IntroduceShipPayload 69 | # } 70 | 71 | defmodule StarWars.Schema do 72 | alias StarWars.Database 73 | 74 | use Absinthe.Schema 75 | use Absinthe.Relay.Schema, :classic 76 | 77 | alias Absinthe.Relay.Connection 78 | 79 | query do 80 | field :rebels, :faction do 81 | resolve fn _, _ -> 82 | Database.get_rebels() 83 | end 84 | end 85 | 86 | field :empire, :faction do 87 | resolve fn _, _ -> 88 | Database.get_empire() 89 | end 90 | end 91 | 92 | node field do 93 | resolve fn 94 | %{type: node_type, id: id}, _ -> 95 | Database.get(node_type, id) 96 | 97 | _, _ -> 98 | {:ok, nil} 99 | end 100 | end 101 | end 102 | 103 | @desc "A ship in the Star Wars saga" 104 | node object(:ship) do 105 | @desc "The name of the ship." 106 | field :name, :string 107 | end 108 | 109 | node interface do 110 | resolve_type fn 111 | %{ships: _}, _ -> 112 | :faction 113 | 114 | _, _ -> 115 | :ship 116 | end 117 | end 118 | 119 | @desc "A faction in the Star Wars saga" 120 | node object(:faction) do 121 | @desc "The name of the faction" 122 | field :name, :string 123 | 124 | @desc "The ships used by the faction." 125 | connection field :ships, node_type: :ship do 126 | resolve fn resolve_args, %{source: faction} -> 127 | Connection.from_list( 128 | Enum.map(faction.ships, fn id -> 129 | with {:ok, value} <- Database.get(:ship, id) do 130 | value 131 | end 132 | end), 133 | resolve_args 134 | ) 135 | end 136 | end 137 | end 138 | 139 | # Default 140 | connection(node_type: :ship) 141 | end 142 | -------------------------------------------------------------------------------- /lib/absinthe/relay/schema/notation.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Schema.Notation do 2 | @moduledoc """ 3 | Used to extend a module where Absinthe types are defined with 4 | Relay-specific macros and types. 5 | 6 | See `Absinthe.Relay`. 7 | """ 8 | 9 | @typedoc "A valid flavor" 10 | @type flavor :: :classic | :modern 11 | 12 | @valid_flavors [:classic, :modern] 13 | 14 | # TODO: Change to `:modern` in v1.5 15 | @default_flavor :classic 16 | 17 | @flavor_namespaces [ 18 | modern: Modern, 19 | classic: Classic 20 | ] 21 | 22 | defmacro __using__(flavor) when flavor in @valid_flavors do 23 | notations(flavor) 24 | end 25 | 26 | defmacro __using__([]) do 27 | [ 28 | # TODO: Remove warning in v1.5 29 | quote do 30 | warning = """ 31 | Defaulting to :classic as the flavor of Relay to target. \ 32 | Note this defaulting behavior will change to :modern in absinthe_relay v1.5. \ 33 | To prevent seeing this notice in the meantime, explicitly provide :classic \ 34 | or :modern as an option when you use Absinthe.Relay.Schema or \ 35 | Absinthe.Relay.Schema.Notation. See the Absinthe.Relay @moduledoc \ 36 | for more information. \ 37 | """ 38 | 39 | IO.warn(warning) 40 | end, 41 | notations(@default_flavor) 42 | ] 43 | end 44 | 45 | @spec notations(flavor) :: Macro.t() 46 | defp notations(flavor) do 47 | mutation_notation = Absinthe.Relay.Mutation.Notation |> flavored(flavor) 48 | 49 | quote do 50 | import Absinthe.Relay.Node.Notation, only: :macros 51 | import Absinthe.Relay.Node.Helpers 52 | import Absinthe.Relay.Connection.Notation, only: :macros 53 | import unquote(mutation_notation), only: :macros 54 | end 55 | end 56 | 57 | @spec flavored(module, flavor) :: module 58 | defp flavored(module, flavor) do 59 | Module.safe_concat(module, Keyword.fetch!(@flavor_namespaces, flavor)) 60 | end 61 | 62 | @doc false 63 | def input(style, identifier, block) do 64 | quote do 65 | # We need to go up 2 levels so we can create the input object 66 | Absinthe.Schema.Notation.stash() 67 | Absinthe.Schema.Notation.stash() 68 | 69 | input_object unquote(identifier) do 70 | private(:absinthe_relay, :input, {:fill, unquote(style)}) 71 | unquote(block) 72 | end 73 | 74 | # Back down to finish the field 75 | Absinthe.Schema.Notation.pop() 76 | Absinthe.Schema.Notation.pop() 77 | end 78 | end 79 | 80 | @doc false 81 | def output(style, identifier, block) do 82 | quote do 83 | Absinthe.Schema.Notation.stash() 84 | Absinthe.Schema.Notation.stash() 85 | 86 | object unquote(identifier) do 87 | private(:absinthe_relay, :payload, {:fill, unquote(style)}) 88 | unquote(block) 89 | end 90 | 91 | Absinthe.Schema.Notation.pop() 92 | Absinthe.Schema.Notation.pop() 93 | end 94 | end 95 | 96 | @doc false 97 | def payload(meta, [field_ident | rest], block) do 98 | block = rewrite_input_output(field_ident, block) 99 | 100 | {:field, meta, [field_ident, ident(field_ident, :payload) | rest] ++ [[do: block]]} 101 | end 102 | 103 | defp rewrite_input_output(field_ident, block) do 104 | Macro.prewalk(block, fn 105 | {:input, meta, [[do: block]]} -> 106 | {:input, meta, [ident(field_ident, :input), [do: block]]} 107 | 108 | {:output, meta, [[do: block]]} -> 109 | {:output, meta, [ident(field_ident, :payload), [do: block]]} 110 | 111 | node -> 112 | node 113 | end) 114 | end 115 | 116 | @doc false 117 | def ident(base_identifier, category) do 118 | :"#{base_identifier}_#{category}" 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Contact: conduct@absinthe-graphql.org 4 | 5 | ## Why have a Code of Conduct? 6 | 7 | As contributors and maintainers of this project, we are committed to providing a friendly, safe and welcoming environment for all, regardless of age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. 8 | 9 | The goal of the Code of Conduct is to specify a baseline standard of behavior so that people with different social values and communication styles can talk about the Absinthe project effectively, productively, and respectfully, even in face of disagreements. The Code of Conduct also provides a mechanism for resolving conflicts in the community when they arise. 10 | 11 | ## Our Values 12 | 13 | These are the values developers that participate in Absinthe project-related activities should aspire to: 14 | 15 | * Be friendly and welcoming 16 | * Be patient 17 | * Remember that people have varying communication styles and that not everyone is using their native language. (Meaning and tone can be lost in translation.) 18 | * Be thoughtful 19 | * Productive communication requires effort. Think about how your words will be interpreted. 20 | * Remember that sometimes it is best to refrain entirely from commenting. 21 | * Be respectful 22 | * In particular, respect differences of opinion. It is important that we resolve disagreements and differing views constructively. 23 | * Avoid destructive behavior 24 | * Derailing: stay on topic; if you want to talk about something else, start a new conversation. 25 | * Unconstructive criticism: don't merely decry the current state of affairs; offer (or at least solicit) suggestions as to how things may be improved. 26 | * Snarking (pithy, unproductive, sniping comments). 27 | 28 | The following actions are explicitly forbidden: 29 | 30 | * Insulting, demeaning, hateful, or threatening remarks. 31 | * Discrimination based on age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. 32 | * Bullying or systematic harassment. 33 | * Unwelcome sexual advances. 34 | * Incitement to any of these. 35 | 36 | ## Where does the Code of Conduct apply? 37 | 38 | If you participate in or contribute to the Absinthe project ecosystem in any way, you are encouraged to follow the Code of Conduct while doing so. 39 | 40 | Explicit enforcement of the Code of Conduct applies to the official mediums operated by the Absinthe project: 41 | 42 | * The official GitHub projects and code reviews. 43 | * The absinthe-graphql.org website and documentation. 44 | * The #absinthe-graphql Slack channel. 45 | 46 | Activities related to Absinthe (conference talks, meetups, and other unofficial forums) are encouraged to adopt this Code of Conduct. Such groups must provide their own contact information. 47 | 48 | Project maintainers may remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. 49 | 50 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by emailing: conduct@absinthe-graphql.org. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. **All reports will be kept confidential**. 51 | 52 | **The goal of the Code of Conduct is to resolve conflicts in the most harmonious way possible**. We hope that in most cases issues may be resolved through polite discussion and mutual agreement. Bannings and other forceful measures are to be employed only as a last resort. **Do not** post about the issue publicly or try to rally sentiment against a particular individual or group. 53 | 54 | ## Acknowledgements 55 | 56 | This document was based on the Code of Conduct from the Elixir project, which is based on the Code of Conduct from the Go project with parts derived from Django's Code of Conduct, Rust's Code of Conduct, and the Contributor Covenant. 57 | -------------------------------------------------------------------------------- /lib/absinthe/relay/node/notation.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Node.Notation do 2 | @moduledoc """ 3 | Macros used to define Node-related schema entities 4 | 5 | See `Absinthe.Relay.Node` for examples of use. 6 | 7 | If you wish to use this module on its own without `use Absinthe.Relay` you 8 | need to include 9 | ``` 10 | @pipeline_modifier Absinthe.Relay.Schema 11 | ``` 12 | in your root schema module. 13 | """ 14 | 15 | alias Absinthe.Blueprint.Schema 16 | 17 | @doc """ 18 | Define a node interface, field, or object type for a schema. 19 | 20 | See the `Absinthe.Relay.Node` module documentation for examples. 21 | """ 22 | 23 | defmacro node({:interface, meta, [attrs]}, do: block) when is_list(attrs) do 24 | do_interface(meta, attrs, block) 25 | end 26 | 27 | defmacro node({:interface, meta, attrs}, do: block) do 28 | do_interface(meta, attrs, block) 29 | end 30 | 31 | defmacro node({:field, meta, [attrs]}, do: block) when is_list(attrs) do 32 | do_field(meta, attrs, block) 33 | end 34 | 35 | defmacro node({:field, meta, attrs}, do: block) do 36 | do_field(meta, attrs, block) 37 | end 38 | 39 | defmacro node({:object, meta, [identifier, attrs]}, do: block) when is_list(attrs) do 40 | do_object(meta, identifier, attrs, block) 41 | end 42 | 43 | defmacro node({:object, meta, [identifier]}, do: block) do 44 | do_object(meta, identifier, [], block) 45 | end 46 | 47 | defp do_interface(meta, attrs, block) do 48 | attrs = attrs || [] 49 | {id_type, attrs} = Keyword.pop(attrs, :id_type, get_id_type()) 50 | 51 | block = [interface_body(id_type), block] 52 | attrs = [:node | [attrs]] 53 | {:interface, meta, attrs ++ [[do: block]]} 54 | end 55 | 56 | defp do_field(meta, attrs, block) do 57 | attrs = attrs || [] 58 | {id_type, attrs} = Keyword.pop(attrs, :id_type, get_id_type()) 59 | 60 | {:field, meta, [:node, :node, attrs ++ [do: [field_body(id_type), block]]]} 61 | end 62 | 63 | defp do_object(meta, identifier, attrs, block) do 64 | {id_fetcher, attrs} = Keyword.pop(attrs, :id_fetcher) 65 | {id_type, attrs} = Keyword.pop(attrs, :id_type, get_id_type()) 66 | 67 | block = [ 68 | quote do 69 | private(:absinthe_relay, :node, {:fill, unquote(__MODULE__)}) 70 | private(:absinthe_relay, :id_fetcher, unquote(id_fetcher)) 71 | end, 72 | object_body(id_fetcher, id_type), 73 | block 74 | ] 75 | 76 | {:object, meta, [identifier, attrs] ++ [[do: block]]} 77 | end 78 | 79 | def additional_types(_, _), do: [] 80 | 81 | # def fillout(:node, %Schema.ObjectTypeDefinition{} = obj) do 82 | # id_field = id_field_template() |> Map.put(:middleware, []) 83 | 84 | # %{obj | interfaces: [:node | obj.interfaces], fields: [id_field | obj.fields]} 85 | # end 86 | 87 | def fillout(_, %Schema.ObjectTypeDefinition{identifier: :faction} = obj) do 88 | obj 89 | end 90 | 91 | def fillout(_, node) do 92 | node 93 | end 94 | 95 | defp get_id_type() do 96 | Absinthe.Relay 97 | |> Application.get_env(:node_id_type, :id) 98 | end 99 | 100 | # An id field is automatically configured 101 | defp interface_body(id_type) do 102 | quote do 103 | field(:id, non_null(unquote(id_type)), description: "The ID of the object.") 104 | end 105 | end 106 | 107 | # An id arg is automatically added 108 | defp field_body(id_type) do 109 | quote do 110 | @desc "The ID of an object." 111 | arg(:id, non_null(unquote(id_type))) 112 | 113 | middleware({Absinthe.Relay.Node, :resolve_with_global_id}) 114 | end 115 | end 116 | 117 | # Automatically add: 118 | # - An id field that resolves to the generated global ID 119 | # for an object of this type 120 | # - A declaration that this implements the node interface 121 | defp object_body(id_fetcher, id_type) do 122 | quote do 123 | @desc "The ID of an object" 124 | field :id, non_null(unquote(id_type)) do 125 | middleware {Absinthe.Relay.Node, :global_id_resolver}, unquote(id_fetcher) 126 | end 127 | 128 | interface(:node) 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.4.4 - 2018-09-20 4 | 5 | - Feature: Enhancements to Connection macros to support extensibility of edge types. See [PR #109](https://github.com/absinthe-graphql/absinthe_relay/pull/109) (Thanks, @coderdan!) 6 | 7 | ## 1.4.3 - 2018-05-09 8 | 9 | - Docs: Better links in generated documentation, updated links to specifications. (Thanks, @Gazler, @jackmarchant!) 10 | - Feature: Update `Absinthe.Relay.Connection` handling of pagination information to match the latest spec. See [PR #114](https://github.com/absinthe-graphql/absinthe_relay/pull/114) for more information. (Thanks, @ndreynolds!) 11 | - Bugfix: Better handling of errors relating to bad cursors given as arguments to `Absinthe.Relay.Connection`. See [PR #110](https://github.com/absinthe-graphql/absinthe_relay/pull/110) for more information. (Thanks, @bernardd!) 12 | - Feature: Support overriding the global ID translators used for `Absinthe.Relay.Node`. See [PR #93](https://github.com/absinthe-graphql/absinthe_relay/pull/93) for more details. (Thanks, @avitex!) 13 | 14 | ## 1.4.2 - 2017-12-04 15 | 16 | - Feature: Support overriding the resolver for `Absinthe.Relay.Connection` edge node fields. See [PR #99](https://github.com/absinthe-graphql/absinthe_relay/pull/99) for more details. 17 | 18 | ## 1.4.1 - 2017-11-22 19 | 20 | - Bug Fix: Fix issue with `:modern` flavor + ParseIDs middleware. See [PR #96](https://github.com/absinthe-graphql/absinthe_relay/pull/96) for more information. 21 | 22 | ## 1.4.0 - 2017-11-13 23 | 24 | - Feature: Support `null` values in `ParseIDs` middleware (passed through as `nil` args) 25 | - Bug Fix: Support `null` values for `before` and `after` pagination arguments (expected by Relay Modern) 26 | 27 | ## 1.3.6 - 2017-09-13 28 | 29 | - Type Spec Fix: Relax type constraints around `Connection.from_query` 30 | 31 | ## 1.3.5 - 2017-08-26 32 | 33 | - Bug Fix: (Connection) Fix original issue with `from_query` where `has_next_page` wasn't correctly reported in some instances. We now request `limit + 1` records to determine if there's a next page (vs using a second count query), use the overage to determine if there are more records, and return `limit` records. See [PR #79](https://github.com/absinthe-graphql/absinthe_relay/pull/79). 34 | 35 | ## 1.3.4 - 2017-08-22 36 | 37 | - Enhancement: (Node) Better logging support when global ID generation fails due to 38 | a missing local ID in the source value. See [PR #77](https://github.com/absinthe-graphql/absinthe_relay/pull/77). 39 | - Bug Fix: (Connection) Fix issue where `has_next_page` is reported as `true` incorrectly; when the number of records and limit are the same. See [PR #76](https://github.com/absinthe-graphql/absinthe_relay/pull/76). 40 | 41 | ## 1.3.3 - 2017-08-20 42 | 43 | - Bug Fix (Node): Fix regression with the `Absinthe.Relay.Node.ParseIDs` middleware when used in conjunction with 44 | the `Absinthe.Relay.Mutation` middleware. See [PR #73](https://github.com/absinthe-graphql/absinthe_relay/pull/73). 45 | for details. 46 | 47 | ## 1.3.2 - 2017-08-18 48 | 49 | - Enhancement (Node): `Absinthe.Relay.Node.ParseIDs` can now decode lists of IDs. See 50 | the module docs, [PR #69](https://github.com/absinthe-graphql/absinthe_relay/pull/69) for details. 51 | - Bug Fix (Connection): Make `Absinthe.Connection.from_slice/2` more forgiving if a `nil` 52 | value is passed in as the `offset`. See [PR #70](https://github.com/absinthe-graphql/absinthe_relay/pull/70) 53 | for details. 54 | 55 | ## 1.3.1 - 2017-06-15 56 | 57 | - Enhancement (Node): `Absinthe.Relay.Node.ParseIDs` can now decode nested values! See 58 | the module docs for details. 59 | - Enhancement (Node): Improved error message when node ids cannot be parsed at all. 60 | 61 | ## 1.3.0 - 2017-04-25 62 | 63 | - Breaking Change (Connection): The functions in the `Connection` module that produce connections 64 | now return `{:ok, connection}` or `{:error, reason}` as they do internal error handling 65 | of connection related arguments 66 | 67 | - Enhancement (Node): Added `Absinthe.Relay.Node.ParseIDs` middleware. Use it instead of 68 | `Absinthe.Relay.Helpers.parsing_node_ids/2`, which will be removed in a future 69 | release. 70 | - Enhancement (Node): Allow multiple possible node types when parsing node IDs. 71 | (Thanks, @avitex.) 72 | - Bug Fix (Node): Handle errors when parsing multiple arguments for node IDs more 73 | gracefully. (Thanks to @avitex and @dpehrson.) 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Absinthe.Relay 2 | 3 | [![Build Status](https://github.com/absinthe-graphql/absinthe_relay/workflows/CI/badge.svg)](https://github.com/absinthe-graphql/absinthe_relay/actions?query=workflow%3ACI) 4 | [![Version](https://img.shields.io/hexpm/v/absinthe_relay.svg)](https://hex.pm/packages/absinthe_relay) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/absinthe_relay/) 6 | [![Download](https://img.shields.io/hexpm/dt/absinthe_relay.svg)](https://hex.pm/packages/absinthe_relay) 7 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 8 | [![Last Updated](https://img.shields.io/github/last-commit/absinthe-graphql/absinthe_relay.svg)](https://github.com/absinthe-graphql/absinthe_relay/commits/master) 9 | 10 | Support for the [Relay framework](https://facebook.github.io/relay/) 11 | from Elixir, using the [Absinthe](https://github.com/absinthe-graphql/absinthe) 12 | package. 13 | 14 | ## Installation 15 | 16 | Install from [Hex.pm](https://hex.pm/packages/absinthe_relay): 17 | 18 | ```elixir 19 | def deps do 20 | [ 21 | {:absinthe_relay, "~> 1.5.0"} 22 | ] 23 | end 24 | ``` 25 | 26 | Note: Absinthe.Relay requires Elixir 1.10 or higher. 27 | 28 | ## Upgrading 29 | 30 | See [CHANGELOG](./CHANGELOG.md) for upgrade steps between versions. 31 | 32 | You may want to look for the specific upgrade guide in the [Absinthe documentation](https://hexdocs.pm/absinthe). 33 | 34 | ## Documentation 35 | 36 | See "Usage," below, for basic usage information and links to specific resources. 37 | 38 | - [Absinthe.Relay hexdocs](https://hexdocs.pm/absinthe_relay). 39 | - For the tutorial, guides, and general information about Absinthe-related 40 | projects, see [http://absinthe-graphql.org](http://absinthe-graphql.org). 41 | 42 | ## Related Projects 43 | 44 | See the [GitHub organization](https://github.com/absinthe-graphql). 45 | 46 | ## Usage 47 | 48 | Schemas should `use Absinthe.Relay.Schema`, optionally providing what flavor of Relay they'd like to support (`:classic` or `:modern`): 49 | 50 | ```elixir 51 | defmodule Schema do 52 | use Absinthe.Schema 53 | use Absinthe.Relay.Schema, :modern 54 | 55 | # ... 56 | 57 | end 58 | ``` 59 | 60 | For a type module, use `Absinthe.Relay.Schema.Notation` 61 | 62 | ```elixir 63 | defmodule Schema do 64 | use Absinthe.Schema.Notation 65 | use Absinthe.Relay.Schema.Notation, :modern 66 | 67 | # ... 68 | 69 | end 70 | ``` 71 | 72 | Note that if you do not provide a flavor option, it will choose the default of `:classic`, but warn you 73 | that this behavior will change to `:modern` in absinthe_relay v1.5. 74 | 75 | 76 | See the documentation for [Absinthe.Relay.Node](https://hexdocs.pm/absinthe_relay/Absinthe.Relay.Node.html), 77 | [Absinthe.Relay.Connection](https://hexdocs.pm/absinthe_relay/Absinthe.Relay.Connection.html), and [Absinthe.Relay.Mutation](https://hexdocs.pm/absinthe_relay/Absinthe.Relay.Mutation.html) for 78 | specific usage information. 79 | 80 | ### Node Interface 81 | 82 | Relay 83 | [requires an interface](https://facebook.github.io/relay/docs/en/graphql-server-specification.html#object-identification), 84 | `"Node"`, be defined in your schema to provide a simple way to fetch 85 | objects using a global ID scheme. 86 | 87 | See the [Absinthe.Relay.Node](https://hexdocs.pm/absinthe_relay/Absinthe.Relay.Node.html) 88 | module documentation for specific instructions on how do design a schema that makes use of nodes. 89 | 90 | ### Connection 91 | 92 | Relay uses 93 | [Connection](https://facebook.github.io/relay/docs/en/graphql-in-relay.html#connectionkey-string-filters-string) 94 | (and other related) types to provide a standardized way of slicing and 95 | paginating a one-to-many relationship. 96 | 97 | Support in this package is designed to match the [Relay Cursor Connection Specification](https://facebook.github.io/relay/docs/en/graphql-server-specification.html#connections). 98 | 99 | See the [Absinthe.Relay.Connection](https://hexdocs.pm/absinthe_relay/Absinthe.Relay.Connection.html) 100 | module documentation for specific instructions on how do design a schema that makes use of nodes. 101 | 102 | ### Mutation 103 | 104 | Relay supports mutation via [a contract](https://facebook.github.io/relay/docs/en/graphql-server-specification.html#mutations) involving single input object arguments (optionally for Relay Modern) with client mutation IDs (only for Relay Classic). 105 | 106 | See the [Absinthe.Relay.Mutation](https://hexdocs.pm/absinthe_relay/Absinthe.Relay.Mutation.html) module documentation for specific instructions on how to design a schema that makes use of mutations. 107 | 108 | ## Supporting the Babel Relay Plugin 109 | 110 | To generate a `schema.json` file for use with the [Babel Relay Plugin](https://facebook.github.io/relay/docs/en/installation-and-setup.html#set-up-babel-plugin-relay), run the `absinthe.schema.json` Mix task, built-in to [Absinthe](https://github.com/absinthe-graphql/absinthe). 111 | 112 | In your project, check out the documentation with: 113 | 114 | ``` 115 | mix help absinthe.schema.json 116 | ``` 117 | 118 | ## Community 119 | 120 | The project is under constant improvement by a growing list of 121 | contributors, and your feedback is important. Please join us in Slack 122 | (`#absinthe-graphql` under the Elixir Slack account) or the Elixir Forum 123 | (tagged `absinthe`). 124 | 125 | Please remember that all interactions in our official spaces follow 126 | our [Code of Conduct](./CODE_OF_CONDUCT.md). 127 | 128 | ## Contributing 129 | 130 | Please follow [contribution guide](./CONTRIBUTING.md). 131 | 132 | ## License 133 | 134 | See [LICENSE.md](./LICENSE.md). 135 | -------------------------------------------------------------------------------- /lib/absinthe/relay/mutation/notation/modern.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Mutation.Notation.Modern do 2 | @moduledoc """ 3 | Convenience macros for Relay Modern mutations. 4 | 5 | (If you want `clientMutationId` handling, see `Absinthe.Relay.Mutation.Notation.Classic`!) 6 | 7 | The `payload` macro can be used by schema designers to support mutation 8 | fields that receive either: 9 | 10 | - A single non-null input object argument (using the `input` macro in this module) 11 | - Any arguments you want to use (using the normal `arg` macro) 12 | 13 | More information can be found at https://facebook.github.io/relay/docs/mutations.html 14 | 15 | ## Example 16 | 17 | In this example we add a mutation field `:simple_mutation` that 18 | accepts an `input` argument of a new type (which is defined for us 19 | because we use the `input` macro), which contains an `:input_data` 20 | field. 21 | 22 | We also declare the output will contain a field, `:result`. 23 | 24 | Notice the `resolve` function doesn't need to know anything about the 25 | wrapping `input` argument -- it only concerns itself with the contents. 26 | The input fields are passed to the resolver just like they were declared 27 | as separate top-level arguments. 28 | 29 | ``` 30 | mutation do 31 | payload field :simple_mutation do 32 | input do 33 | field :input_data, non_null(:integer) 34 | end 35 | output do 36 | field :result, :integer 37 | end 38 | resolve fn 39 | %{input_data: input_data}, _ -> 40 | # Some mutation side-effect here 41 | {:ok, %{result: input_data * 2}} 42 | end 43 | end 44 | end 45 | ``` 46 | 47 | Here's a query document that would hit this field: 48 | 49 | ```graphql 50 | mutation DoSomethingSimple { 51 | simpleMutation(input: {inputData: 2}) { 52 | result 53 | } 54 | } 55 | ``` 56 | 57 | And here's the response: 58 | 59 | ```json 60 | { 61 | "data": { 62 | "simpleMutation": { 63 | "result": 4 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | Note the above code would create the following types in our schema, ad hoc: 70 | 71 | - `SimpleMutationInput` 72 | - `SimpleMutationPayload` 73 | 74 | For this reason, the identifier passed to `payload field` must be unique 75 | across your schema. 76 | 77 | ## Using your own arguments 78 | 79 | You are free to just declare your own arguments instead. The `input` argument and type behavior 80 | is only activated for your mutation field if you use the `input` macro. 81 | 82 | You're free to define your own arguments using `arg`, as usual, with one caveat: don't call one `:input`. 83 | 84 | ## The Escape Hatch 85 | 86 | The mutation macros defined here are just for convenience; if you want something that goes against these 87 | restrictions, don't worry! You can always just define your types and fields using normal (`field`, `arg`, 88 | `input_object`, etc) schema notation macros as usual. 89 | """ 90 | alias Absinthe.Blueprint 91 | alias Absinthe.Blueprint.Schema 92 | alias Absinthe.Relay.Schema.Notation 93 | 94 | @doc """ 95 | Define a mutation with a single input and a client mutation ID. See the module documentation for more information. 96 | """ 97 | defmacro payload({:field, meta, args}, do: block) do 98 | Notation.payload(meta, args, [block_private(), block]) 99 | end 100 | 101 | defmacro payload({:field, meta, args}) do 102 | Notation.payload(meta, args, block_private()) 103 | end 104 | 105 | defp block_private() do 106 | # This indicates to the Relay schema phase that this field should automatically 107 | # generate the payload type for this field if it is not explicitly created 108 | quote do 109 | private(:absinthe_relay, :payload, {:fill, unquote(__MODULE__)}) 110 | end 111 | end 112 | 113 | # 114 | # INPUT 115 | # 116 | 117 | @doc """ 118 | Defines the input type for your payload field. See the module documentation for an example. 119 | """ 120 | defmacro input(identifier, do: block) do 121 | [ 122 | # Only if the `input` macro is actually used should we mark the field 123 | # as using an input type, autogenerating the `input` argument on the field. 124 | quote do 125 | private(:absinthe_relay, :input, {:fill, unquote(__MODULE__)}) 126 | end, 127 | Notation.input(__MODULE__, identifier, block) 128 | ] 129 | end 130 | 131 | # 132 | # PAYLOAD 133 | # 134 | 135 | @doc """ 136 | Defines the output (payload) type for your payload field. See the module documentation for an example. 137 | """ 138 | defmacro output(identifier, do: block) do 139 | Notation.output(__MODULE__, identifier, block) 140 | end 141 | 142 | def additional_types(:payload, %Schema.FieldDefinition{identifier: field_ident}) do 143 | %Schema.ObjectTypeDefinition{ 144 | name: Notation.ident(field_ident, :payload) |> Atom.to_string() |> Macro.camelize(), 145 | identifier: Notation.ident(field_ident, :payload), 146 | module: __MODULE__, 147 | __private__: [absinthe_relay: [payload: {:fill, __MODULE__}]], 148 | __reference__: Absinthe.Schema.Notation.build_reference(__ENV__) 149 | } 150 | end 151 | 152 | def additional_types(_, _), do: [] 153 | 154 | def fillout(:input, %Schema.FieldDefinition{} = field) do 155 | add_input_arg(field) 156 | end 157 | 158 | def fillout(_, node) do 159 | node 160 | end 161 | 162 | def add_input_arg(field) do 163 | arg = %Schema.InputValueDefinition{ 164 | identifier: :input, 165 | name: "input", 166 | type: %Blueprint.TypeReference.NonNull{of_type: Notation.ident(field.identifier, :input)}, 167 | module: __MODULE__, 168 | __reference__: Absinthe.Schema.Notation.build_reference(__ENV__) 169 | } 170 | 171 | %{ 172 | field 173 | | arguments: [arg | field.arguments], 174 | middleware: [Absinthe.Relay.Mutation | field.middleware] 175 | } 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/absinthe/relay/mutation/notation/classic.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Mutation.Notation.Classic do 2 | @moduledoc """ 3 | Support for Relay Classic mutations with single inputs and client mutation IDs. 4 | 5 | The `payload` macro can be used by schema designers to support mutation 6 | fields that receive a single input object argument with a client mutation ID 7 | and return that ID as part of the response payload. 8 | 9 | More information can be found at https://facebook.github.io/relay/docs/guides-mutations.html 10 | 11 | ## Example 12 | 13 | In this example we add a mutation field `:simple_mutation` that 14 | accepts an `input` argument (which is defined for us automatically) 15 | which contains an `:input_data` field. 16 | 17 | We also declare the output will contain a field, `:result`. 18 | 19 | Notice the `resolve` function doesn't need to know anything about the 20 | wrapping `input` argument -- it only concerns itself with the contents 21 | -- and the client mutation ID doesn't need to be dealt with, either. It 22 | will be returned as part of the response payload automatically. 23 | 24 | ``` 25 | mutation do 26 | payload field :simple_mutation do 27 | input do 28 | field :input_data, non_null(:integer) 29 | end 30 | output do 31 | field :result, :integer 32 | end 33 | resolve fn 34 | %{input_data: input_data}, _ -> 35 | # Some mutation side-effect here 36 | {:ok, %{result: input_data * 2}} 37 | end 38 | end 39 | end 40 | ``` 41 | 42 | Here's a query document that would hit this field: 43 | 44 | ```graphql 45 | mutation DoSomethingSimple { 46 | simpleMutation(input: {inputData: 2, clientMutationId: "abc"}) { 47 | result 48 | clientMutationId 49 | } 50 | } 51 | ``` 52 | 53 | And here's the response: 54 | 55 | ```json 56 | { 57 | "data": { 58 | "simpleMutation": { 59 | "result": 4, 60 | "clientMutationId": "abc" 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | Note the above code would create the following types in our schema, ad hoc: 67 | 68 | - `SimpleMutationInput` 69 | - `SimpleMutationPayload` 70 | 71 | For this reason, the identifier passed to `payload field` must be unique 72 | across your schema. 73 | 74 | ## The Escape Hatch 75 | 76 | The mutation macros defined here are just for convenience; if you want something that goes against these 77 | restrictions, don't worry! You can always just define your types and fields using normal (`field`, `arg`, 78 | `input_object`, etc) schema notation macros as usual. 79 | """ 80 | use Absinthe.Schema.Notation 81 | alias Absinthe.Blueprint 82 | alias Absinthe.Blueprint.Schema 83 | alias Absinthe.Relay.Schema.Notation 84 | 85 | @doc """ 86 | Define a mutation with a single input and a client mutation ID. See the module documentation for more information. 87 | """ 88 | 89 | defmacro payload({:field, meta, args}, do: block) do 90 | Notation.payload(meta, args, [default_private(), block]) 91 | end 92 | 93 | defmacro payload({:field, meta, args}) do 94 | Notation.payload(meta, args, default_private()) 95 | end 96 | 97 | defp default_private() do 98 | [ 99 | # This indicates to the Relay schema phase that this field should automatically 100 | # generate both input and payload types if they are not defined within the field 101 | # itself. The `input` notation also autogenerates the `input` argument to the field 102 | quote do 103 | private(:absinthe_relay, :payload, {:fill, unquote(__MODULE__)}) 104 | private(:absinthe_relay, :input, {:fill, unquote(__MODULE__)}) 105 | end 106 | ] 107 | end 108 | 109 | # 110 | # INPUT 111 | # 112 | 113 | @doc """ 114 | Defines the input type for your payload field. See the module documentation for an example. 115 | """ 116 | defmacro input(identifier, do: block) do 117 | Notation.input(__MODULE__, identifier, block) 118 | end 119 | 120 | # 121 | # PAYLOAD 122 | # 123 | 124 | @doc """ 125 | Defines the output (payload) type for your payload field. See the module documentation for an example. 126 | """ 127 | defmacro output(identifier, do: block) do 128 | Notation.output(__MODULE__, identifier, block) 129 | end 130 | 131 | def additional_types(:input, %Schema.FieldDefinition{identifier: field_ident}) do 132 | %Schema.InputObjectTypeDefinition{ 133 | name: Notation.ident(field_ident, :input) |> Atom.to_string() |> Macro.camelize(), 134 | identifier: Notation.ident(field_ident, :input), 135 | module: __MODULE__, 136 | __private__: [absinthe_relay: [input: {:fill, __MODULE__}]], 137 | __reference__: Absinthe.Schema.Notation.build_reference(__ENV__) 138 | } 139 | end 140 | 141 | def additional_types(:payload, %Schema.FieldDefinition{identifier: field_ident}) do 142 | %Schema.ObjectTypeDefinition{ 143 | name: Notation.ident(field_ident, :payload) |> Atom.to_string() |> Macro.camelize(), 144 | identifier: Notation.ident(field_ident, :payload), 145 | module: __MODULE__, 146 | __private__: [absinthe_relay: [payload: {:fill, __MODULE__}]], 147 | __reference__: Absinthe.Schema.Notation.build_reference(__ENV__) 148 | } 149 | end 150 | 151 | def additional_types(_, _), do: [] 152 | 153 | def fillout(:input, %Schema.FieldDefinition{} = field) do 154 | Absinthe.Relay.Mutation.Notation.Modern.add_input_arg(field) 155 | end 156 | 157 | def fillout(:input, %Schema.InputObjectTypeDefinition{} = input) do 158 | # We could add this to the additional_types above, but we also need to fill 159 | # out this field if the user specified the types. It's easier to leave it out 160 | # of the defaults, and then unconditionally apply it after the fact. 161 | %{input | fields: [client_mutation_id_field() | input.fields]} 162 | end 163 | 164 | def fillout(:payload, %Schema.ObjectTypeDefinition{} = payload) do 165 | %{payload | fields: [client_mutation_id_field() | payload.fields]} 166 | end 167 | 168 | def fillout(_, node) do 169 | node 170 | end 171 | 172 | defp client_mutation_id_field() do 173 | %Blueprint.Schema.FieldDefinition{ 174 | name: "client_mutation_id", 175 | identifier: :client_mutation_id, 176 | type: %Blueprint.TypeReference.NonNull{of_type: :string}, 177 | module: __MODULE__, 178 | __reference__: Absinthe.Schema.Notation.build_reference(__ENV__) 179 | } 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /test/lib/absinthe/relay/mutation/classic_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Mutation.ClassicTest do 2 | use Absinthe.Relay.Case, async: true 3 | 4 | defmodule Schema do 5 | use Absinthe.Schema 6 | use Absinthe.Relay.Schema, :classic 7 | 8 | query do 9 | end 10 | 11 | mutation do 12 | payload field(:simple_mutation) do 13 | input do 14 | field :input_data, :integer 15 | end 16 | 17 | output do 18 | field :result, :integer 19 | end 20 | 21 | resolve fn %{input_data: input_data}, _ -> 22 | {:ok, %{result: input_data * 2}} 23 | end 24 | end 25 | end 26 | end 27 | 28 | describe "mutation_with_client_mutation_id" do 29 | @query """ 30 | mutation M { 31 | simpleMutation { 32 | result 33 | } 34 | } 35 | """ 36 | test "requires an `input' argument" do 37 | assert {:ok, 38 | %{ 39 | errors: [ 40 | %{ 41 | message: 42 | ~s(In argument "input": Expected type "SimpleMutationInput!", found null.) 43 | } 44 | ] 45 | }} = Absinthe.run(@query, Schema) 46 | end 47 | 48 | @query """ 49 | mutation M { 50 | simpleMutation(input: {clientMutationId: "abc", input_data: 1}) { 51 | result 52 | clientMutationId 53 | } 54 | } 55 | """ 56 | @expected %{ 57 | data: %{ 58 | "simpleMutation" => %{ 59 | "result" => 2, 60 | "clientMutationId" => "abc" 61 | } 62 | } 63 | } 64 | test "returns the same client mutation ID and resolves as expected" do 65 | assert {:ok, @expected} == Absinthe.run(@query, Schema) 66 | end 67 | end 68 | 69 | describe "introspection" do 70 | @query """ 71 | { 72 | __type(name: "SimpleMutationInput") { 73 | name 74 | kind 75 | inputFields { 76 | name 77 | type { 78 | name 79 | kind 80 | ofType { 81 | name 82 | kind 83 | } 84 | } 85 | } 86 | } 87 | } 88 | """ 89 | @expected %{ 90 | data: %{ 91 | "__type" => %{ 92 | "name" => "SimpleMutationInput", 93 | "kind" => "INPUT_OBJECT", 94 | "inputFields" => [ 95 | %{ 96 | "name" => "clientMutationId", 97 | "type" => %{ 98 | "name" => nil, 99 | "kind" => "NON_NULL", 100 | "ofType" => %{ 101 | "name" => "String", 102 | "kind" => "SCALAR" 103 | } 104 | } 105 | }, 106 | %{ 107 | "name" => "inputData", 108 | "type" => %{ 109 | "name" => "Int", 110 | "kind" => "SCALAR", 111 | "ofType" => nil 112 | } 113 | } 114 | ] 115 | } 116 | } 117 | } 118 | test "contains correct input" do 119 | assert {:ok, @expected} = Absinthe.run(@query, Schema) 120 | end 121 | 122 | @query """ 123 | { 124 | __type(name: "SimpleMutationPayload") { 125 | name 126 | kind 127 | fields { 128 | name 129 | type { 130 | name 131 | kind 132 | ofType { 133 | name 134 | kind 135 | } 136 | } 137 | } 138 | } 139 | } 140 | """ 141 | @expected %{ 142 | data: %{ 143 | "__type" => %{ 144 | "name" => "SimpleMutationPayload", 145 | "kind" => "OBJECT", 146 | "fields" => [ 147 | %{ 148 | "name" => "clientMutationId", 149 | "type" => %{ 150 | "name" => nil, 151 | "kind" => "NON_NULL", 152 | "ofType" => %{ 153 | "name" => "String", 154 | "kind" => "SCALAR" 155 | } 156 | } 157 | }, 158 | %{ 159 | "name" => "result", 160 | "type" => %{ 161 | "name" => "Int", 162 | "kind" => "SCALAR", 163 | "ofType" => nil 164 | } 165 | } 166 | ] 167 | } 168 | } 169 | } 170 | 171 | test "contains correct payload" do 172 | assert {:ok, @expected} == Absinthe.run(@query, Schema) 173 | end 174 | end 175 | 176 | @query """ 177 | { 178 | __schema { 179 | mutationType { 180 | fields { 181 | name 182 | args { 183 | name 184 | type { 185 | name 186 | kind 187 | ofType { 188 | name 189 | kind 190 | } 191 | } 192 | } 193 | type { 194 | name 195 | kind 196 | } 197 | } 198 | } 199 | } 200 | } 201 | """ 202 | @expected %{ 203 | data: %{ 204 | "__schema" => %{ 205 | "mutationType" => %{ 206 | "fields" => [ 207 | %{ 208 | "name" => "simpleMutation", 209 | "args" => [ 210 | %{ 211 | "name" => "input", 212 | "type" => %{ 213 | "name" => nil, 214 | "kind" => "NON_NULL", 215 | "ofType" => %{ 216 | "name" => "SimpleMutationInput", 217 | "kind" => "INPUT_OBJECT" 218 | } 219 | } 220 | } 221 | ], 222 | "type" => %{ 223 | "name" => "SimpleMutationPayload", 224 | "kind" => "OBJECT" 225 | } 226 | } 227 | ] 228 | } 229 | } 230 | } 231 | } 232 | 233 | test "returns the correct field" do 234 | assert {:ok, @expected} == Absinthe.run(@query, Schema) 235 | end 236 | 237 | describe "an empty definition" do 238 | defmodule EmptyInputAndResultSchema do 239 | use Absinthe.Schema 240 | use Absinthe.Relay.Schema, :classic 241 | 242 | query do 243 | end 244 | 245 | mutation do 246 | payload(field :without_block, resolve: fn _, _ -> {:ok, %{}} end) 247 | 248 | payload field :with_block_and_attrs, resolve: fn _, _ -> {:ok, %{}} end do 249 | end 250 | 251 | payload field(:with_block) do 252 | resolve fn _, _ -> 253 | # Logic is there 254 | {:ok, %{}} 255 | end 256 | end 257 | end 258 | end 259 | 260 | @cm_id "abc" 261 | 262 | @query """ 263 | mutation M { 264 | withoutBlock(input: {clientMutationId: "#{@cm_id}"}) { 265 | clientMutationId 266 | } 267 | } 268 | """ 269 | test "supports returning the client mutation id intact when defined without a block" do 270 | assert {:ok, %{data: %{"withoutBlock" => %{"clientMutationId" => @cm_id}}}} == 271 | Absinthe.run(@query, EmptyInputAndResultSchema) 272 | end 273 | 274 | @query """ 275 | mutation M { 276 | withBlock(input: {clientMutationId: "#{@cm_id}"}) { 277 | clientMutationId 278 | } 279 | } 280 | """ 281 | test "supports returning the client mutation id intact when defined with a block" do 282 | assert {:ok, %{data: %{"withBlock" => %{"clientMutationId" => @cm_id}}}} == 283 | Absinthe.run(@query, EmptyInputAndResultSchema) 284 | end 285 | 286 | @query """ 287 | mutation M { 288 | withBlockAndAttrs(input: {clientMutationId: "#{@cm_id}"}) { 289 | clientMutationId 290 | } 291 | } 292 | """ 293 | test "supports returning the client mutation id intact when defined with a block and attrs" do 294 | assert {:ok, %{data: %{"withBlockAndAttrs" => %{"clientMutationId" => @cm_id}}}} == 295 | Absinthe.run(@query, EmptyInputAndResultSchema) 296 | end 297 | end 298 | end 299 | -------------------------------------------------------------------------------- /test/lib/absinthe/relay/schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.SchemaTest do 2 | use Absinthe.Relay.Case, async: true 3 | 4 | alias Absinthe.Type 5 | 6 | @jack_global_id Base.encode64("Person:jack") 7 | 8 | @papers_global_id Base.encode64("Business:papers") 9 | 10 | @binx_global_id Base.encode64("Kitten:binx") 11 | 12 | defmodule Schema do 13 | use Absinthe.Schema 14 | use Absinthe.Relay.Schema, :classic 15 | 16 | @people %{ 17 | "jack" => %{id: "jack", name: "Jack", age: 35}, 18 | "jill" => %{id: "jill", name: "Jill", age: 31} 19 | } 20 | @businesses %{ 21 | "papers" => %{id: "papers", name: "Papers, Inc!", employee_count: 100}, 22 | "toilets" => %{id: "toilets", name: "Toilets International", employee_count: 1} 23 | } 24 | @cats %{"binx" => %{tag: "binx", name: "Mr. Binx", whisker_count: 12}} 25 | 26 | query do 27 | field :version, :string do 28 | resolve fn _, _ -> 29 | {:ok, "0.1.2"} 30 | end 31 | end 32 | 33 | node field do 34 | resolve fn 35 | %{type: :person, id: id}, _ -> 36 | {:ok, Map.get(@people, id)} 37 | 38 | %{type: :business, id: id}, _ -> 39 | {:ok, Map.get(@businesses, id)} 40 | 41 | %{type: :cat, id: id}, _ -> 42 | {:ok, Map.get(@cats, id)} 43 | end 44 | end 45 | end 46 | 47 | @desc "My Interface" 48 | node interface do 49 | resolve_type fn 50 | %{age: _}, _ -> 51 | :person 52 | 53 | %{employee_count: _}, _ -> 54 | :business 55 | 56 | %{whisker_count: _}, _ -> 57 | :cat 58 | 59 | _, _ -> 60 | nil 61 | end 62 | end 63 | 64 | node object(:person) do 65 | field :name, :string 66 | field :age, :string 67 | end 68 | 69 | node object(:business) do 70 | field :name, :string 71 | field :employee_count, :integer 72 | end 73 | 74 | node object(:cat, name: "Kitten", id_fetcher: &tag_id_fetcher/2) do 75 | field :name, :string 76 | field :whisker_count, :integer 77 | end 78 | 79 | defp tag_id_fetcher(%{tag: value}, _), do: value 80 | defp tag_id_fetcher(_, _), do: nil 81 | end 82 | 83 | describe "using node interface" do 84 | test "creates the :node type" do 85 | assert %Type.Interface{ 86 | name: "Node", 87 | description: "My Interface", 88 | fields: %{id: %Type.Field{name: "id", type: %Type.NonNull{of_type: :id}}} 89 | } = Schema.__absinthe_type__(:node) 90 | end 91 | end 92 | 93 | describe "using node field" do 94 | test "creates the :node field" do 95 | assert %{fields: %{node: %{name: "node", type: :node, middleware: middleware}}} = 96 | Schema.__absinthe_type__(:query) 97 | 98 | middleware = Absinthe.Middleware.unshim(middleware, Schema) 99 | 100 | assert [ 101 | {Absinthe.Middleware.Telemetry, []}, 102 | {{Absinthe.Relay.Node, :resolve_with_global_id}, []}, 103 | {{Absinthe.Resolution, :call}, _} 104 | ] = middleware 105 | end 106 | end 107 | 108 | describe "using node object" do 109 | test "creates the object" do 110 | assert %{name: "Kitten"} = Schema.__absinthe_type__(:cat) 111 | end 112 | end 113 | 114 | describe "using the node field and a global ID configured with an identifier" do 115 | @query """ 116 | { 117 | node(id: "#{@jack_global_id}") { 118 | id 119 | ... on Person { name } 120 | } 121 | } 122 | """ 123 | test "resolves using the global ID" do 124 | assert {:ok, %{data: %{"node" => %{"id" => @jack_global_id, "name" => "Jack"}}}} = 125 | Absinthe.run(@query, Schema) 126 | end 127 | end 128 | 129 | describe "using the node field and a global ID configured with a binary" do 130 | @query """ 131 | { 132 | node(id: "#{@papers_global_id}") { 133 | id 134 | ... on Business { name } 135 | } 136 | } 137 | """ 138 | test "resolves using the global ID" do 139 | assert {:ok, %{data: %{"node" => %{"id" => @papers_global_id, "name" => "Papers, Inc!"}}}} = 140 | Absinthe.run(@query, Schema) 141 | end 142 | end 143 | 144 | describe "using the node field and a custom id fetcher defined as an attribute" do 145 | @query """ 146 | { 147 | node(id: "#{@binx_global_id}") { 148 | id 149 | } 150 | } 151 | """ 152 | test "resolves using the global ID" do 153 | assert {:ok, %{data: %{"node" => %{"id" => @binx_global_id}}}} = 154 | Absinthe.run(@query, Schema) 155 | end 156 | end 157 | 158 | defmodule SchemaCustomIdType do 159 | use Absinthe.Schema 160 | use Absinthe.Relay.Schema, :classic 161 | 162 | @people %{ 163 | "jack" => %{id: "jack", name: "Jack", age: 35}, 164 | "jill" => %{id: "jill", name: "Jill", age: 31} 165 | } 166 | @businesses %{ 167 | "papers" => %{id: "papers", name: "Papers, Inc!", employee_count: 100}, 168 | "toilets" => %{id: "toilets", name: "Toilets International", employee_count: 1} 169 | } 170 | @cats %{"binx" => %{tag: "binx", name: "Mr. Binx", whisker_count: 12}} 171 | 172 | query do 173 | field :version, :string do 174 | resolve fn _, _ -> 175 | {:ok, "0.1.2"} 176 | end 177 | end 178 | 179 | node field(id_type: :string) do 180 | resolve fn 181 | %{type: :person, id: id}, _ -> 182 | {:ok, Map.get(@people, id)} 183 | 184 | %{type: :business, id: id}, _ -> 185 | {:ok, Map.get(@businesses, id)} 186 | 187 | %{type: :cat, id: id}, _ -> 188 | {:ok, Map.get(@cats, id)} 189 | end 190 | end 191 | end 192 | 193 | @desc "My Interface" 194 | node interface(id_type: :string) do 195 | resolve_type fn 196 | %{age: _}, _ -> 197 | :person 198 | 199 | %{employee_count: _}, _ -> 200 | :business 201 | 202 | %{whisker_count: _}, _ -> 203 | :cat 204 | 205 | _, _ -> 206 | nil 207 | end 208 | end 209 | 210 | node object(:person, id_type: :string) do 211 | field :name, :string 212 | field :age, :string 213 | end 214 | 215 | node object(:business, id_type: :string) do 216 | field :name, :string 217 | field :employee_count, :integer 218 | end 219 | 220 | node object(:cat, name: "Kitten", id_fetcher: &tag_id_fetcher/2, id_type: :string) do 221 | field :name, :string 222 | field :whisker_count, :integer 223 | end 224 | 225 | defp tag_id_fetcher(%{tag: value}, _), do: value 226 | defp tag_id_fetcher(_, _), do: nil 227 | end 228 | 229 | describe "using node interface with custom id type" do 230 | test "creates the :node type" do 231 | assert %Type.Interface{ 232 | name: "Node", 233 | description: "My Interface", 234 | fields: %{id: %Type.Field{name: "id", type: %Type.NonNull{of_type: :string}}} 235 | } = SchemaCustomIdType.__absinthe_type__(:node) 236 | end 237 | end 238 | 239 | describe "using node field with custom id type" do 240 | test "creates the :node field" do 241 | assert %{fields: %{node: %{name: "node", type: :node, middleware: middleware}}} = 242 | SchemaCustomIdType.__absinthe_type__(:query) 243 | 244 | middleware = Absinthe.Middleware.unshim(middleware, SchemaCustomIdType) 245 | 246 | assert [ 247 | {Absinthe.Middleware.Telemetry, []}, 248 | {{Absinthe.Relay.Node, :resolve_with_global_id}, []}, 249 | {{Absinthe.Resolution, :call}, _} 250 | ] = middleware 251 | end 252 | end 253 | 254 | describe "using node object with custom id type" do 255 | test "creates the object" do 256 | assert %{name: "Kitten"} = SchemaCustomIdType.__absinthe_type__(:cat) 257 | end 258 | end 259 | 260 | describe "using the node field and a global ID configured with an identifier and custom id type" do 261 | @query """ 262 | { 263 | node(id: "#{@jack_global_id}") { 264 | id 265 | ... on Person { name } 266 | } 267 | } 268 | """ 269 | test "resolves using the global ID" do 270 | assert {:ok, %{data: %{"node" => %{"id" => @jack_global_id, "name" => "Jack"}}}} = 271 | Absinthe.run(@query, SchemaCustomIdType) 272 | end 273 | end 274 | 275 | describe "using the node field and a global ID configured with a binary and custom id type" do 276 | @query """ 277 | { 278 | node(id: "#{@papers_global_id}") { 279 | id 280 | ... on Business { name } 281 | } 282 | } 283 | """ 284 | test "resolves using the global ID" do 285 | assert {:ok, %{data: %{"node" => %{"id" => @papers_global_id, "name" => "Papers, Inc!"}}}} = 286 | Absinthe.run(@query, SchemaCustomIdType) 287 | end 288 | end 289 | 290 | describe "using the node field and a custom id fetcher defined as an attribute and custom id type" do 291 | @query """ 292 | { 293 | node(id: "#{@binx_global_id}") { 294 | id 295 | } 296 | } 297 | """ 298 | test "resolves using the global ID" do 299 | assert {:ok, %{data: %{"node" => %{"id" => @binx_global_id}}}} = 300 | Absinthe.run(@query, SchemaCustomIdType) 301 | end 302 | end 303 | end 304 | -------------------------------------------------------------------------------- /test/star_wars/connection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StarWars.ConnectionTest do 2 | use Absinthe.Relay.Case, async: true 3 | 4 | describe "Backwards Pagination" do 5 | test "can start from the end of a list" do 6 | query = """ 7 | query RebelsShipsQuery { 8 | rebels { 9 | name, 10 | ships1: ships(last: 2) { 11 | edges { 12 | node { 13 | name 14 | } 15 | } 16 | pageInfo { 17 | hasPreviousPage 18 | hasNextPage 19 | } 20 | } 21 | ships2: ships(last: 5) { 22 | pageInfo { 23 | hasPreviousPage 24 | hasNextPage 25 | } 26 | } 27 | } 28 | } 29 | """ 30 | 31 | expected = %{ 32 | "rebels" => %{ 33 | "name" => "Alliance to Restore the Republic", 34 | "ships1" => %{ 35 | "edges" => [ 36 | %{ 37 | "node" => %{ 38 | "name" => "Millenium Falcon" 39 | } 40 | }, 41 | %{ 42 | "node" => %{ 43 | "name" => "Home One" 44 | } 45 | } 46 | ], 47 | "pageInfo" => %{ 48 | "hasPreviousPage" => true, 49 | "hasNextPage" => false 50 | } 51 | }, 52 | "ships2" => %{"pageInfo" => %{"hasNextPage" => false, "hasPreviousPage" => false}} 53 | } 54 | } 55 | 56 | assert {:ok, %{data: expected}} == Absinthe.run(query, StarWars.Schema) 57 | end 58 | 59 | test "should calculate hasNextPage correctly" do 60 | query = """ 61 | query RebelsShipsQuery { 62 | rebels { 63 | ships(last: 2) { 64 | pageInfo { 65 | startCursor 66 | } 67 | } 68 | } 69 | } 70 | """ 71 | 72 | assert {:ok, %{data: data}} = Absinthe.run(query, StarWars.Schema) 73 | cursor = data["rebels"]["ships"]["pageInfo"]["startCursor"] 74 | 75 | query = """ 76 | query RebelsShipsQuery { 77 | rebels { 78 | ships(last: 3, before: "#{cursor}") { 79 | edges { 80 | node { 81 | name 82 | } 83 | } 84 | pageInfo { 85 | hasNextPage 86 | hasPreviousPage 87 | } 88 | } 89 | } 90 | } 91 | """ 92 | 93 | expected = %{ 94 | "rebels" => %{ 95 | "ships" => %{ 96 | "edges" => [ 97 | %{ 98 | "node" => %{ 99 | "name" => "X-Wing" 100 | } 101 | }, 102 | %{ 103 | "node" => %{ 104 | "name" => "Y-Wing" 105 | } 106 | }, 107 | %{ 108 | "node" => %{ 109 | "name" => "A-Wing" 110 | } 111 | } 112 | ], 113 | "pageInfo" => %{ 114 | "hasPreviousPage" => false, 115 | "hasNextPage" => true 116 | } 117 | } 118 | } 119 | } 120 | 121 | assert {:ok, %{data: expected}} == Absinthe.run(query, StarWars.Schema) 122 | end 123 | end 124 | 125 | describe "Star Wars connections" do 126 | test "fetches the first ship of the rebels" do 127 | query = """ 128 | query RebelsShipsQuery { 129 | rebels { 130 | name, 131 | ships(first: 1) { 132 | edges { 133 | node { 134 | name 135 | } 136 | } 137 | } 138 | } 139 | } 140 | """ 141 | 142 | expected = %{ 143 | "rebels" => %{ 144 | "name" => "Alliance to Restore the Republic", 145 | "ships" => %{ 146 | "edges" => [ 147 | %{ 148 | "node" => %{ 149 | "name" => "X-Wing" 150 | } 151 | } 152 | ] 153 | } 154 | } 155 | } 156 | 157 | assert {:ok, %{data: expected}} == Absinthe.run(query, StarWars.Schema) 158 | end 159 | 160 | test "fetches the first two ships of the rebels with a cursor" do 161 | query = """ 162 | query MoreRebelShipsQuery { 163 | rebels { 164 | name, 165 | ships(first: 2) { 166 | edges { 167 | cursor, 168 | node { 169 | name 170 | } 171 | } 172 | } 173 | } 174 | } 175 | """ 176 | 177 | expected = %{ 178 | "rebels" => %{ 179 | "name" => "Alliance to Restore the Republic", 180 | "ships" => %{ 181 | "edges" => [ 182 | %{ 183 | "cursor" => "YXJyYXljb25uZWN0aW9uOjA=", 184 | "node" => %{ 185 | "name" => "X-Wing" 186 | } 187 | }, 188 | %{ 189 | "cursor" => "YXJyYXljb25uZWN0aW9uOjE=", 190 | "node" => %{ 191 | "name" => "Y-Wing" 192 | } 193 | } 194 | ] 195 | } 196 | } 197 | } 198 | 199 | assert {:ok, %{data: expected}} == Absinthe.run(query, StarWars.Schema) 200 | end 201 | 202 | test "fetches the next three ships of the rebels with a cursor" do 203 | query = """ 204 | query EndOfRebelShipsQuery { 205 | rebels { 206 | name, 207 | ships(first: 3, after: "YXJyYXljb25uZWN0aW9uOjE=") { 208 | edges { 209 | cursor, 210 | node { 211 | name 212 | } 213 | } 214 | } 215 | } 216 | } 217 | """ 218 | 219 | expected = %{ 220 | "rebels" => %{ 221 | "name" => "Alliance to Restore the Republic", 222 | "ships" => %{ 223 | "edges" => [ 224 | %{ 225 | "cursor" => "YXJyYXljb25uZWN0aW9uOjI=", 226 | "node" => %{ 227 | "name" => "A-Wing" 228 | } 229 | }, 230 | %{ 231 | "cursor" => "YXJyYXljb25uZWN0aW9uOjM=", 232 | "node" => %{ 233 | "name" => "Millenium Falcon" 234 | } 235 | }, 236 | %{ 237 | "cursor" => "YXJyYXljb25uZWN0aW9uOjQ=", 238 | "node" => %{ 239 | "name" => "Home One" 240 | } 241 | } 242 | ] 243 | } 244 | } 245 | } 246 | 247 | assert {:ok, %{data: expected}} == Absinthe.run(query, StarWars.Schema) 248 | end 249 | 250 | test "fetches no ships of the rebels at the end of connection" do 251 | query = """ 252 | query RebelsQuery { 253 | rebels { 254 | name, 255 | ships(first: 3, after: "YXJyYXljb25uZWN0aW9uOjQ=") { 256 | edges { 257 | cursor, 258 | node { 259 | name 260 | } 261 | } 262 | } 263 | } 264 | } 265 | """ 266 | 267 | expected = %{ 268 | "rebels" => %{ 269 | "name" => "Alliance to Restore the Republic", 270 | "ships" => %{ 271 | "edges" => [] 272 | } 273 | } 274 | } 275 | 276 | assert {:ok, %{data: expected}} == Absinthe.run(query, StarWars.Schema) 277 | end 278 | 279 | test "identifies the end of the list" do 280 | query = """ 281 | query EndOfRebelShipsQuery { 282 | rebels { 283 | name, 284 | originalShips: ships(first: 2) { 285 | edges { 286 | node { 287 | name 288 | } 289 | } 290 | pageInfo { 291 | hasNextPage 292 | } 293 | } 294 | moreShips: ships(first: 3, after: "YXJyYXljb25uZWN0aW9uOjE=") { 295 | edges { 296 | node { 297 | name 298 | } 299 | } 300 | pageInfo { 301 | hasNextPage 302 | } 303 | } 304 | } 305 | } 306 | """ 307 | 308 | expected = %{ 309 | "rebels" => %{ 310 | "name" => "Alliance to Restore the Republic", 311 | "originalShips" => %{ 312 | "edges" => [ 313 | %{ 314 | "node" => %{ 315 | "name" => "X-Wing" 316 | } 317 | }, 318 | %{ 319 | "node" => %{ 320 | "name" => "Y-Wing" 321 | } 322 | } 323 | ], 324 | "pageInfo" => %{ 325 | "hasNextPage" => true 326 | } 327 | }, 328 | "moreShips" => %{ 329 | "edges" => [ 330 | %{ 331 | "node" => %{ 332 | "name" => "A-Wing" 333 | } 334 | }, 335 | %{ 336 | "node" => %{ 337 | "name" => "Millenium Falcon" 338 | } 339 | }, 340 | %{ 341 | "node" => %{ 342 | "name" => "Home One" 343 | } 344 | } 345 | ], 346 | "pageInfo" => %{ 347 | "hasNextPage" => false 348 | } 349 | } 350 | } 351 | } 352 | 353 | assert {:ok, %{data: expected}} == Absinthe.run(query, StarWars.Schema) 354 | end 355 | end 356 | end 357 | -------------------------------------------------------------------------------- /test/lib/absinthe/relay/node_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.NodeTest do 2 | use Absinthe.Relay.Case, async: true 3 | import ExUnit.CaptureLog 4 | 5 | alias Absinthe.Relay.Node 6 | 7 | defmodule Schema do 8 | use Absinthe.Schema 9 | use Absinthe.Relay.Schema, :classic 10 | 11 | @foos %{ 12 | "1" => %{id: "1", name: "Bar 1"}, 13 | "2" => %{id: "2", name: "Bar 2"} 14 | } 15 | 16 | node interface do 17 | resolve_type fn _, _ -> 18 | # We just resolve :foos for now 19 | :foo 20 | end 21 | end 22 | 23 | node object(:foo) do 24 | field :name, :string 25 | end 26 | 27 | node object(:other_foo, name: "FancyFoo") do 28 | field :name, :string 29 | end 30 | 31 | query do 32 | field :single_foo, :foo do 33 | arg :id, non_null(:id) 34 | resolve parsing_node_ids(&resolve_foo/2, id: :foo) 35 | end 36 | 37 | field :single_foo_with_multiple_node_types, :foo do 38 | arg :id, non_null(:id) 39 | resolve parsing_node_ids(&resolve_foo/2, id: [:foo, :bar]) 40 | end 41 | 42 | field :dual_foo, list_of(:foo) do 43 | arg :id1, non_null(:id) 44 | arg :id2, non_null(:id) 45 | resolve parsing_node_ids(&resolve_foos/2, id1: :foo, id2: :foo) 46 | end 47 | 48 | field :dual_foo_with_multiple_node_types, list_of(:foo) do 49 | arg :id1, non_null(:id) 50 | arg :id2, non_null(:id) 51 | resolve parsing_node_ids(&resolve_foos/2, id1: [:foo, :bar], id2: [:foo, :bar]) 52 | end 53 | end 54 | 55 | defp resolve_foo(%{id: %{type: :foo, id: id}}, _) do 56 | {:ok, Map.get(@foos, id)} 57 | end 58 | 59 | defp resolve_foo(%{id: id}, _) do 60 | {:ok, Map.get(@foos, id)} 61 | end 62 | 63 | defp resolve_foos(%{id1: %{type: :foo, id: id1}, id2: %{type: :foo, id: id2}}, _) do 64 | { 65 | :ok, 66 | [ 67 | Map.get(@foos, id1), 68 | Map.get(@foos, id2) 69 | ] 70 | } 71 | end 72 | 73 | defp resolve_foos(%{id1: id1, id2: id2}, _) do 74 | { 75 | :ok, 76 | [ 77 | Map.get(@foos, id1), 78 | Map.get(@foos, id2) 79 | ] 80 | } 81 | end 82 | end 83 | 84 | @foo1_id Base.encode64("Foo:1") 85 | @foo2_id Base.encode64("Foo:2") 86 | 87 | describe "global_id_resolver" do 88 | test "returns a function that returns an error when a global id can't be resolved" do 89 | fun = fn -> 90 | resolver = Absinthe.Relay.Node.global_id_resolver(:other_foo, nil) 91 | resolver.(%{}, %{schema: Schema, source: %{}}) 92 | end 93 | 94 | base = "No source non-global ID value could be fetched from the source object" 95 | # User error 96 | capture_log(fn -> assert {:error, base} == fun.() end) 97 | # Developer: warn level 98 | assert capture_log(fun) =~ base <> " (type FancyFoo)" 99 | # Developer: debug level 100 | debug = capture_log(fun) 101 | assert debug =~ base 102 | assert debug =~ "%{" 103 | end 104 | end 105 | 106 | describe "to_global_id" do 107 | test "works given an atom for an existing type" do 108 | assert !is_nil(Node.to_global_id(:foo, 1, Schema)) 109 | end 110 | 111 | test "returns nil, given an atom for an non-existing type" do 112 | assert is_nil(Node.to_global_id(:not_foo, 1, Schema)) 113 | end 114 | 115 | test "works given a binary and internal ID" do 116 | assert Node.to_global_id("Foo", 1, Schema) 117 | end 118 | 119 | test "gives the same global ID for different type, equivalent references" do 120 | assert Node.to_global_id("FancyFoo", 1, Schema) == Node.to_global_id(:other_foo, 1, Schema) 121 | end 122 | 123 | test "gives the different global ID for different type, equivalent references" do 124 | assert Node.to_global_id("FancyFoo", 1, Schema) != Node.to_global_id(:foo, 1, Schema) 125 | end 126 | 127 | test "fails given a bad ID" do 128 | assert is_nil(Node.to_global_id("Foo", nil, Schema)) 129 | end 130 | end 131 | 132 | describe "global_id_translator" do 133 | test "default is base64 when schema passed is nil" do 134 | assert Node.global_id_translator(nil) == Absinthe.Relay.Node.IDTranslator.Base64 135 | end 136 | 137 | test "default is base64 when not schema is not configured" do 138 | assert Node.global_id_translator(Schema) == Absinthe.Relay.Node.IDTranslator.Base64 139 | end 140 | end 141 | 142 | describe "parsing_node_id" do 143 | test "parses one id correctly" do 144 | result = 145 | ~s<{ singleFoo(id: "#{@foo1_id}") { id name } }> 146 | |> Absinthe.run(Schema) 147 | 148 | assert {:ok, %{data: %{"singleFoo" => %{"name" => "Bar 1", "id" => @foo1_id}}}} == result 149 | end 150 | 151 | test "handles one incorrect id with a single expected type" do 152 | result = 153 | ~s<{ singleFoo(id: "#{Node.to_global_id(:other_foo, 1, Schema)}") { id name } }> 154 | |> Absinthe.run(Schema) 155 | 156 | assert {:ok, 157 | %{ 158 | data: %{}, 159 | errors: [ 160 | %{ 161 | message: 162 | ~s 163 | } 164 | ] 165 | }} = result 166 | end 167 | 168 | test "handles one incorrect id with a multiple expected types" do 169 | result = 170 | ~s<{ singleFooWithMultipleNodeTypes(id: "#{Node.to_global_id(:other_foo, 1, Schema)}") { id name } }> 171 | |> Absinthe.run(Schema) 172 | 173 | assert {:ok, 174 | %{ 175 | data: %{}, 176 | errors: [ 177 | %{ 178 | message: 179 | ~s 180 | } 181 | ] 182 | }} = result 183 | end 184 | 185 | test "handles one correct id with a multiple expected types" do 186 | result = 187 | ~s<{ singleFooWithMultipleNodeTypes(id: "#{@foo1_id}") { id name } }> 188 | |> Absinthe.run(Schema) 189 | 190 | assert {:ok, 191 | %{ 192 | data: %{ 193 | "singleFooWithMultipleNodeTypes" => %{"name" => "Bar 1", "id" => @foo1_id} 194 | } 195 | }} == result 196 | end 197 | 198 | test "parses multiple ids correctly" do 199 | result = 200 | ~s<{ dualFoo(id1: "#{@foo1_id}", id2: "#{@foo2_id}") { id name } }> 201 | |> Absinthe.run(Schema) 202 | 203 | assert {:ok, 204 | %{ 205 | data: %{ 206 | "dualFoo" => [ 207 | %{"name" => "Bar 1", "id" => @foo1_id}, 208 | %{"name" => "Bar 2", "id" => @foo2_id} 209 | ] 210 | } 211 | }} == result 212 | end 213 | 214 | test "handles multiple incorrect ids" do 215 | result = 216 | ~s<{ dualFoo(id1: "#{Node.to_global_id(:other_foo, 1, Schema)}", id2: "#{ 217 | Node.to_global_id(:other_foo, 2, Schema) 218 | }") { id name } }> 219 | |> Absinthe.run(Schema) 220 | 221 | assert {:ok, 222 | %{ 223 | data: %{}, 224 | errors: [ 225 | %{ 226 | message: 227 | ~s(In argument "id1": Expected node type in ["Foo"], found "FancyFoo".) 228 | }, 229 | %{ 230 | message: 231 | ~s(In argument "id2": Expected node type in ["Foo"], found "FancyFoo".) 232 | } 233 | ] 234 | }} = result 235 | end 236 | 237 | test "handles multiple incorrect ids with multiple node types" do 238 | result = 239 | ~s<{ dualFooWithMultipleNodeTypes(id1: "#{Node.to_global_id(:other_foo, 1, Schema)}", id2: "#{ 240 | Node.to_global_id(:other_foo, 2, Schema) 241 | }") { id name } }> 242 | |> Absinthe.run(Schema) 243 | 244 | assert {:ok, 245 | %{ 246 | data: %{}, 247 | errors: [ 248 | %{ 249 | message: 250 | ~s(In argument "id1": Expected node type in ["Foo"], found "FancyFoo".) 251 | }, 252 | %{ 253 | message: 254 | ~s(In argument "id2": Expected node type in ["Foo"], found "FancyFoo".) 255 | } 256 | ] 257 | }} = result 258 | end 259 | 260 | test "parses multiple ids correctly with multiple node types" do 261 | result = 262 | ~s<{ dualFooWithMultipleNodeTypes(id1: "#{@foo1_id}", id2: "#{@foo2_id}") { id name } }> 263 | |> Absinthe.run(Schema) 264 | 265 | assert {:ok, 266 | %{ 267 | data: %{ 268 | "dualFooWithMultipleNodeTypes" => [ 269 | %{"name" => "Bar 1", "id" => @foo1_id}, 270 | %{"name" => "Bar 2", "id" => @foo2_id} 271 | ] 272 | } 273 | }} == result 274 | end 275 | end 276 | 277 | defmodule ErrorMiddlewareSchema do 278 | defmodule ErrorMiddleware do 279 | @behaviour Absinthe.Middleware 280 | 281 | def call(resolution, _config) do 282 | Absinthe.Resolution.put_result(resolution, {:error, "Error"}) 283 | end 284 | end 285 | 286 | defmodule Root do 287 | defstruct id: "root" 288 | end 289 | 290 | use Absinthe.Schema 291 | use Absinthe.Relay.Schema, :classic 292 | 293 | def middleware(middleware, _field, _object) do 294 | [ErrorMiddleware | middleware] 295 | end 296 | 297 | node interface do 298 | resolve_type fn 299 | %Root{}, _ -> :root 300 | _, _ -> nil 301 | end 302 | end 303 | 304 | node object(:root) do 305 | end 306 | 307 | query do 308 | node field do 309 | resolve fn %{type: :root}, _info -> {:ok, %Root{}} end 310 | end 311 | end 312 | end 313 | 314 | describe "node resolution" do 315 | test "fails gracefully when middleware puts an error into the resolution" do 316 | result = 317 | """ 318 | query node($id: ID!) { 319 | node(id: $id) { 320 | id 321 | } 322 | } 323 | """ 324 | |> Absinthe.run(ErrorMiddlewareSchema, variables: %{"id" => Base.encode64("Root:root")}) 325 | 326 | assert {:ok, %{data: %{"node" => nil}, errors: [%{message: "Error"}]}} = result 327 | end 328 | end 329 | end 330 | -------------------------------------------------------------------------------- /lib/absinthe/relay/connection/notation.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Connection.Notation do 2 | @moduledoc """ 3 | Macros used to define Connection-related schema entities 4 | 5 | See `Absinthe.Relay.Connection` for more information. 6 | 7 | If you wish to use this module on its own without `use Absinthe.Relay` you 8 | need to include 9 | ``` 10 | @pipeline_modifier Absinthe.Relay.Schema 11 | ``` 12 | in your root schema module. 13 | """ 14 | 15 | alias Absinthe.Blueprint.Schema 16 | 17 | @naming_attrs [:node_type, :non_null, :non_null_edges, :non_null_edge, :connection] 18 | 19 | defmodule Naming do 20 | @moduledoc false 21 | 22 | defstruct base_identifier: nil, 23 | node_type_identifier: nil, 24 | connection_type_identifier: nil, 25 | edge_type_identifier: nil, 26 | non_null_edges: false, 27 | non_null_edge: false, 28 | attrs: [] 29 | 30 | def from_attrs!(attrs) do 31 | node_type_identifier = 32 | attrs[:node_type] || 33 | raise( 34 | "Must provide a `:node_type' option (an optional `:connection` option is also supported)" 35 | ) 36 | 37 | base_identifier = attrs[:connection] || node_type_identifier 38 | non_null_edges = attrs[:non_null_edges] || attrs[:non_null] || false 39 | non_null_edge = attrs[:non_null_edge] || attrs[:non_null] || false 40 | 41 | %__MODULE__{ 42 | node_type_identifier: node_type_identifier, 43 | base_identifier: base_identifier, 44 | connection_type_identifier: ident(base_identifier, :connection), 45 | edge_type_identifier: ident(base_identifier, :edge), 46 | non_null_edges: non_null_edges, 47 | non_null_edge: non_null_edge, 48 | attrs: [ 49 | node_type: node_type_identifier, 50 | connection: base_identifier, 51 | non_null_edges: non_null_edges, 52 | non_null_edge: non_null_edge 53 | ] 54 | } 55 | end 56 | 57 | defp ident(base, category) do 58 | :"#{base}_#{category}" 59 | end 60 | end 61 | 62 | @doc """ 63 | Define a connection type for a given node type. 64 | 65 | ## Examples 66 | 67 | A basic connection for a node type, `:pet`. This well generate simple 68 | `:pet_connection` and `:pet_edge` types for you: 69 | 70 | ``` 71 | connection node_type: :pet 72 | ``` 73 | 74 | You can provide a custom name for the connection type (just don't include the 75 | word "connection"). You must still provide the `:node_type`. You can create as 76 | many different connections to a node type as you want. 77 | 78 | This example will create a connection type, `:favorite_pets_connection`, and 79 | an edge type, `:favorite_pets_edge`: 80 | 81 | ``` 82 | connection :favorite_pets, node_type: :pet 83 | ``` 84 | 85 | You can customize the connection object just like any other `object`: 86 | 87 | ``` 88 | connection :favorite_pets, node_type: :pet do 89 | field :total_age, :float do 90 | resolve fn 91 | _, %{source: conn} -> 92 | sum = conn.edges 93 | |> Enum.map(fn edge -> edge.node.age) 94 | |> Enum.sum 95 | {:ok, sum} 96 | end 97 | end 98 | edge do 99 | # ... 100 | end 101 | end 102 | ``` 103 | 104 | Just remember that if you use the block form of `connection`, you must call 105 | the `edge` macro within the block to make sure the edge type is generated. 106 | See the `edge` macro below for more information. 107 | """ 108 | defmacro connection({:field, _, [identifier, attrs]}, do: block) when is_list(attrs) do 109 | do_connection_field(identifier, attrs, block) 110 | end 111 | 112 | defmacro connection(attrs, do: block) do 113 | naming = Naming.from_attrs!(attrs) 114 | do_connection_definition(naming, attrs, block) 115 | end 116 | 117 | defmacro connection(identifier, attrs) do 118 | naming = Naming.from_attrs!(attrs |> Keyword.put(:connection, identifier)) 119 | do_connection_definition(naming, attrs, []) 120 | end 121 | 122 | defmacro connection(attrs) do 123 | naming = Naming.from_attrs!(attrs) 124 | do_connection_definition(naming, attrs, []) 125 | end 126 | 127 | defmacro connection(identifier, attrs, do: block) do 128 | naming = Naming.from_attrs!(attrs |> Keyword.put(:connection, identifier)) 129 | do_connection_definition(naming, attrs, block) 130 | end 131 | 132 | defp do_connection_field(identifier, attrs, block) do 133 | naming = Naming.from_attrs!(attrs) 134 | 135 | paginate = Keyword.get(attrs, :paginate, :both) 136 | 137 | field_attrs = 138 | attrs 139 | |> Keyword.drop([:paginate] ++ @naming_attrs) 140 | |> Keyword.put(:type, naming.connection_type_identifier) 141 | 142 | quote do 143 | field unquote(identifier), unquote(field_attrs) do 144 | private(:absinthe_relay, {:paginate, unquote(paginate)}, {:fill, unquote(__MODULE__)}) 145 | unquote(block) 146 | end 147 | end 148 | end 149 | 150 | defp do_connection_definition(naming, attrs, block) do 151 | identifier = naming.connection_type_identifier 152 | 153 | attrs = Keyword.drop(attrs, @naming_attrs) 154 | 155 | block = name_edge(block, naming.attrs) 156 | edge_field = build_edge_type(naming) 157 | 158 | quote do 159 | object unquote(identifier), unquote(attrs) do 160 | private( 161 | :absinthe_relay, 162 | {:connection, unquote(naming.attrs)}, 163 | {:fill, unquote(__MODULE__)} 164 | ) 165 | 166 | field(:page_info, type: non_null(:page_info)) 167 | field(:edges, type: unquote(edge_field)) 168 | unquote(block) 169 | end 170 | end 171 | end 172 | 173 | defp build_edge_type(%{non_null_edge: true, non_null_edges: true} = naming) do 174 | quote do 175 | non_null(list_of(non_null(unquote(naming.edge_type_identifier)))) 176 | end 177 | end 178 | 179 | defp build_edge_type(%{non_null_edge: true} = naming) do 180 | quote do 181 | list_of(non_null(unquote(naming.edge_type_identifier))) 182 | end 183 | end 184 | 185 | defp build_edge_type(%{non_null_edges: true} = naming) do 186 | quote do 187 | non_null(list_of(unquote(naming.edge_type_identifier))) 188 | end 189 | end 190 | 191 | defp build_edge_type(naming) do 192 | quote do 193 | list_of(unquote(naming.edge_type_identifier)) 194 | end 195 | end 196 | 197 | defp name_edge([], _), do: [] 198 | 199 | defp name_edge({:edge, meta, [[do: block]]}, conn_attrs) do 200 | {:edge, meta, [conn_attrs, [do: block]]} 201 | end 202 | 203 | defp name_edge({:__block__, meta, content}, conn_attrs) do 204 | content = 205 | Enum.map(content, fn 206 | {:edge, meta, [[do: block]]} -> 207 | {:edge, meta, [conn_attrs, [do: block]]} 208 | 209 | {:edge, meta, [attrs, [do: block]]} -> 210 | {:edge, meta, [conn_attrs ++ attrs, [do: block]]} 211 | 212 | node -> 213 | node 214 | end) 215 | 216 | {:__block__, meta, content} 217 | end 218 | 219 | @doc """ 220 | Customize the edge type. 221 | 222 | ## Examples 223 | 224 | ``` 225 | connection node_type: :pet do 226 | # ... 227 | edge do 228 | field :node_name_backwards, :string do 229 | resolve fn 230 | _, %{source: edge} -> 231 | {:ok, edge.node.name |> String.reverse} 232 | end 233 | end 234 | end 235 | end 236 | ``` 237 | """ 238 | defmacro edge(attrs, do: block) do 239 | naming = Naming.from_attrs!(attrs) 240 | 241 | attrs = Keyword.drop(attrs, @naming_attrs) 242 | 243 | quote do 244 | Absinthe.Schema.Notation.stash() 245 | 246 | object unquote(naming.edge_type_identifier), unquote(attrs) do 247 | private(:absinthe_relay, {:edge, unquote(naming.attrs)}, {:fill, unquote(__MODULE__)}) 248 | unquote(block) 249 | end 250 | 251 | Absinthe.Schema.Notation.pop() 252 | end 253 | end 254 | 255 | def additional_types({:connection, attrs}, _) do 256 | naming = Naming.from_attrs!(attrs) 257 | identifier = naming.edge_type_identifier 258 | 259 | %Schema.ObjectTypeDefinition{ 260 | name: identifier |> Atom.to_string() |> Macro.camelize(), 261 | identifier: identifier, 262 | module: __MODULE__, 263 | __private__: [absinthe_relay: [{{:edge, attrs}, {:fill, __MODULE__}}]], 264 | __reference__: Absinthe.Schema.Notation.build_reference(__ENV__) 265 | } 266 | end 267 | 268 | def additional_types(_, _), do: [] 269 | 270 | def fillout({:paginate, type}, node) do 271 | Map.update!(node, :arguments, fn arguments -> 272 | type 273 | |> paginate_args() 274 | |> Enum.map(fn {id, type} -> build_arg(id, type) end) 275 | |> put_uniq(arguments) 276 | end) 277 | end 278 | 279 | # @desc "The item at the end of the edge" 280 | # field(:node, unquote(naming.node_type_identifier)) 281 | # @desc "A cursor for use in pagination" 282 | # field(:cursor, non_null(:string)) 283 | def fillout({:edge, attrs}, node) do 284 | naming = Naming.from_attrs!(attrs) 285 | 286 | Map.update!(node, :fields, fn fields -> 287 | naming.node_type_identifier 288 | |> edge_fields 289 | |> put_uniq(fields) 290 | end) 291 | end 292 | 293 | def fillout(_, node) do 294 | node 295 | end 296 | 297 | defp put_uniq(new, prior) do 298 | existing = MapSet.new(prior, & &1.identifier) 299 | 300 | new 301 | |> Enum.filter(&(!(&1.identifier in existing))) 302 | |> Enum.concat(prior) 303 | end 304 | 305 | defp edge_fields(node_type) do 306 | [ 307 | %Schema.FieldDefinition{ 308 | name: "node", 309 | identifier: :node, 310 | type: node_type, 311 | module: __MODULE__, 312 | __reference__: Absinthe.Schema.Notation.build_reference(__ENV__) 313 | }, 314 | %Schema.FieldDefinition{ 315 | name: "cursor", 316 | identifier: :cursor, 317 | type: :string, 318 | module: __MODULE__, 319 | __reference__: Absinthe.Schema.Notation.build_reference(__ENV__) 320 | } 321 | ] 322 | end 323 | 324 | defp paginate_args(:forward) do 325 | [after: :string, first: :integer] 326 | end 327 | 328 | defp paginate_args(:backward) do 329 | [before: :string, last: :integer] 330 | end 331 | 332 | defp paginate_args(:both) do 333 | paginate_args(:forward) ++ paginate_args(:backward) 334 | end 335 | 336 | defp build_arg(id, type) do 337 | %Schema.InputValueDefinition{ 338 | name: id |> Atom.to_string(), 339 | identifier: id, 340 | type: type, 341 | module: __MODULE__, 342 | __reference__: Absinthe.Schema.Notation.build_reference(__ENV__) 343 | } 344 | end 345 | end 346 | -------------------------------------------------------------------------------- /test/lib/absinthe/relay/pagination_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.PaginationTest do 2 | use Absinthe.Relay.Case, async: true 3 | 4 | defmodule Schema do 5 | use Absinthe.Schema 6 | use Absinthe.Relay.Schema, :classic 7 | 8 | @foos 0..9 |> Enum.map(&%{id: &1, index: &1}) 9 | 10 | node object(:foo) do 11 | field :index, non_null(:integer) 12 | end 13 | 14 | query do 15 | connection field :foos, node_type: :foo do 16 | resolve fn pagination_args, _ -> 17 | Absinthe.Relay.Connection.from_list(@foos, pagination_args) 18 | end 19 | end 20 | 21 | node field do 22 | resolve fn %{type: :foo, id: id}, _ -> 23 | {:ok, Enum.at(@foos, String.to_integer(id))} 24 | end 25 | end 26 | end 27 | 28 | node interface do 29 | resolve_type fn _, _ -> :foo end 30 | end 31 | 32 | connection(node_type: :foo) 33 | end 34 | 35 | test "It handles forward pagination correctly" do 36 | query = """ 37 | query FirstSupportingNullAfter($first: Int!) { 38 | foos(first: $first, after: null) { 39 | page_info { 40 | start_cursor 41 | end_cursor 42 | has_previous_page 43 | has_next_page 44 | } 45 | edges { 46 | cursor 47 | node { 48 | index 49 | } 50 | } 51 | } 52 | } 53 | """ 54 | 55 | assert {:ok, %{data: result}} = Absinthe.run(query, Schema, variables: %{"first" => 3}) 56 | 57 | assert %{ 58 | "foos" => %{ 59 | "page_info" => %{ 60 | "start_cursor" => cursor0, 61 | "end_cursor" => cursor2, 62 | "has_previous_page" => false, 63 | "has_next_page" => true 64 | }, 65 | "edges" => [ 66 | %{ 67 | "cursor" => cursor0, 68 | "node" => %{"index" => 0} 69 | }, 70 | %{ 71 | "cursor" => cursor1, 72 | "node" => %{"index" => 1} 73 | }, 74 | %{ 75 | "cursor" => cursor2, 76 | "node" => %{"index" => 2} 77 | } 78 | ] 79 | } 80 | } = result 81 | 82 | query = """ 83 | query firstSupportingNonNullAfter($first: Int!, $after: String!) { 84 | foos(first: $first, after: $after) { 85 | page_info { 86 | start_cursor 87 | end_cursor 88 | has_previous_page 89 | has_next_page 90 | } 91 | edges { 92 | cursor 93 | node { 94 | index 95 | } 96 | } 97 | } 98 | } 99 | """ 100 | 101 | assert {:ok, %{data: result}} = 102 | Absinthe.run(query, Schema, variables: %{"first" => 3, "after" => cursor1}) 103 | 104 | assert %{ 105 | "foos" => %{ 106 | "page_info" => %{ 107 | "start_cursor" => ^cursor2, 108 | "end_cursor" => cursor4, 109 | "has_previous_page" => true, 110 | "has_next_page" => true 111 | }, 112 | "edges" => [ 113 | %{ 114 | "cursor" => ^cursor2, 115 | "node" => %{"index" => 2} 116 | }, 117 | %{ 118 | "cursor" => _cursor3, 119 | "node" => %{"index" => 3} 120 | }, 121 | %{ 122 | "cursor" => cursor4, 123 | "node" => %{"index" => 4} 124 | } 125 | ] 126 | } 127 | } = result 128 | 129 | assert {:ok, %{data: result}} = 130 | Absinthe.run(query, Schema, variables: %{"first" => 100, "after" => cursor4}) 131 | 132 | assert %{ 133 | "foos" => %{ 134 | "page_info" => %{ 135 | "start_cursor" => cursor5, 136 | "end_cursor" => cursor9, 137 | "has_previous_page" => true, 138 | "has_next_page" => false 139 | }, 140 | "edges" => [ 141 | %{ 142 | "cursor" => cursor5, 143 | "node" => %{"index" => 5} 144 | }, 145 | %{ 146 | "cursor" => _cursor6, 147 | "node" => %{"index" => 6} 148 | }, 149 | %{ 150 | "cursor" => _cursor7, 151 | "node" => %{"index" => 7} 152 | }, 153 | %{ 154 | "cursor" => _cursor8, 155 | "node" => %{"index" => 8} 156 | }, 157 | %{ 158 | "cursor" => cursor9, 159 | "node" => %{"index" => 9} 160 | } 161 | ] 162 | } 163 | } = result 164 | 165 | assert {:ok, %{data: result}} = 166 | Absinthe.run(query, Schema, variables: %{"first" => 100, "after" => cursor9}) 167 | 168 | assert %{ 169 | "foos" => %{ 170 | "page_info" => %{ 171 | "start_cursor" => nil, 172 | "end_cursor" => nil, 173 | "has_previous_page" => true, 174 | "has_next_page" => false 175 | }, 176 | "edges" => [] 177 | } 178 | } = result 179 | end 180 | 181 | test "It handles backward pagination correctly" do 182 | query = """ 183 | query LastSupportingNullBefore($last: Int!) { 184 | foos(last: $last, before: null) { 185 | page_info { 186 | start_cursor 187 | end_cursor 188 | has_previous_page 189 | has_next_page 190 | } 191 | edges { 192 | cursor 193 | node { 194 | index 195 | } 196 | } 197 | } 198 | } 199 | """ 200 | 201 | assert {:ok, %{data: result}} = Absinthe.run(query, Schema, variables: %{"last" => 3}) 202 | 203 | assert %{ 204 | "foos" => %{ 205 | "page_info" => %{ 206 | "start_cursor" => cursor7, 207 | "end_cursor" => cursor9, 208 | "has_previous_page" => true, 209 | "has_next_page" => false 210 | }, 211 | "edges" => [ 212 | %{ 213 | "cursor" => cursor7, 214 | "node" => %{"index" => 7} 215 | }, 216 | %{ 217 | "cursor" => cursor8, 218 | "node" => %{"index" => 8} 219 | }, 220 | %{ 221 | "cursor" => cursor9, 222 | "node" => %{"index" => 9} 223 | } 224 | ] 225 | } 226 | } = result 227 | 228 | query = """ 229 | query LastSupportingNonNullBefore($last: Int!, $before: String!) { 230 | foos(last: $last, before: $before) { 231 | page_info { 232 | start_cursor 233 | end_cursor 234 | has_previous_page 235 | has_next_page 236 | } 237 | edges { 238 | cursor 239 | node { 240 | index 241 | } 242 | } 243 | } 244 | } 245 | """ 246 | 247 | assert {:ok, %{data: result}} = 248 | Absinthe.run(query, Schema, variables: %{"last" => 3, "before" => cursor8}) 249 | 250 | assert %{ 251 | "foos" => %{ 252 | "page_info" => %{ 253 | "start_cursor" => cursor5, 254 | "end_cursor" => ^cursor7, 255 | "has_previous_page" => true, 256 | "has_next_page" => true 257 | }, 258 | "edges" => [ 259 | %{ 260 | "cursor" => cursor5, 261 | "node" => %{"index" => 5} 262 | }, 263 | %{ 264 | "cursor" => _cursor6, 265 | "node" => %{"index" => 6} 266 | }, 267 | %{ 268 | "cursor" => ^cursor7, 269 | "node" => %{"index" => 7} 270 | } 271 | ] 272 | } 273 | } = result 274 | 275 | assert {:ok, %{data: result}} = 276 | Absinthe.run(query, Schema, variables: %{"last" => 100, "before" => cursor5}) 277 | 278 | assert %{ 279 | "foos" => %{ 280 | "page_info" => %{ 281 | "start_cursor" => cursor0, 282 | "end_cursor" => cursor4, 283 | "has_previous_page" => false, 284 | "has_next_page" => true 285 | }, 286 | "edges" => [ 287 | %{ 288 | "cursor" => cursor0, 289 | "node" => %{"index" => 0} 290 | }, 291 | %{ 292 | "cursor" => _cursor1, 293 | "node" => %{"index" => 1} 294 | }, 295 | %{ 296 | "cursor" => _cursor2, 297 | "node" => %{"index" => 2} 298 | }, 299 | %{ 300 | "cursor" => _cursor3, 301 | "node" => %{"index" => 3} 302 | }, 303 | %{ 304 | "cursor" => cursor4, 305 | "node" => %{"index" => 4} 306 | } 307 | ] 308 | } 309 | } = result 310 | 311 | assert {:ok, %{data: result}} = 312 | Absinthe.run(query, Schema, variables: %{"last" => 100, "before" => cursor0}) 313 | 314 | assert %{ 315 | "foos" => %{ 316 | "page_info" => %{ 317 | "start_cursor" => nil, 318 | "end_cursor" => nil, 319 | "has_previous_page" => false, 320 | "has_next_page" => true 321 | }, 322 | "edges" => [] 323 | } 324 | } = result 325 | end 326 | 327 | test "It returns an error if pagination parameters are missing" do 328 | query = """ 329 | { 330 | foos { 331 | page_info { 332 | start_cursor 333 | end_cursor 334 | has_previous_page 335 | has_next_page 336 | } 337 | edges { 338 | cursor 339 | node { 340 | index 341 | } 342 | } 343 | } 344 | } 345 | """ 346 | 347 | assert {:ok, result} = Absinthe.run(query, Schema) 348 | 349 | assert %{ 350 | data: %{}, 351 | errors: [ 352 | %{ 353 | locations: [%{column: 3, line: 2}], 354 | message: "You must either supply `:first` or `:last`" 355 | } 356 | ] 357 | } = result 358 | end 359 | end 360 | -------------------------------------------------------------------------------- /lib/absinthe/relay/node.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Node do 2 | @moduledoc """ 3 | Support for global object identification. 4 | 5 | The `node` macro can be used by schema designers to add required 6 | "object identification" support for object types, and to provide a unified 7 | interface for querying them. 8 | 9 | More information can be found at: 10 | - https://facebook.github.io/relay/docs/graphql-object-identification.html#content 11 | - https://facebook.github.io/relay/graphql/objectidentification.htm 12 | 13 | ## Interface 14 | 15 | Define a node interface for your schema, providing a type resolver that, 16 | given a resolved object can determine which node object type it belongs to. 17 | 18 | ``` 19 | node interface do 20 | resolve_type fn 21 | %{age: _}, _ -> 22 | :person 23 | %{employee_count: _}, _ -> 24 | :business 25 | _, _ -> 26 | nil 27 | end 28 | end 29 | ``` 30 | 31 | This will create an interface, `:node` that expects one field, `:id`, be 32 | defined -- and that the ID will be a global identifier. 33 | 34 | If you use the `node` macro to create your `object` types (see "Object" below), 35 | this can be easily done, layered on top of the standard object type definition 36 | style. 37 | 38 | ## Field 39 | 40 | The node field provides a unified interface to query for an object in the 41 | system using a global ID. The node field should be defined within your schema 42 | `query` and should provide a resolver that, given a map containing the object 43 | type identifier and internal, non-global ID (the incoming global ID will be 44 | parsed into these values for you automatically) can resolve the correct value. 45 | 46 | ``` 47 | query do 48 | 49 | # ... 50 | 51 | node field do 52 | resolve fn 53 | %{type: :person, id: id}, _ -> 54 | {:ok, Map.get(@people, id)} 55 | %{type: :business, id: id}, _ -> 56 | {:ok, Map.get(@businesses, id)} 57 | end 58 | end 59 | 60 | end 61 | ``` 62 | 63 | This creates a field, `:node`, with one argument: `:id`. This is expected to 64 | be a global ID and, once resolved, will result in a value whose type 65 | implements the `:node` interface. 66 | 67 | Here's how you easily create object types that can be looked up using this 68 | field: 69 | 70 | ## Object 71 | 72 | To play nicely with the `:node` interface and field, explained above, any 73 | object types need to implement the `:node` interface and generate a global 74 | ID as the value of its `:id` field. Using the `node` macro, you can easily do 75 | this while retaining the usual object type definition style. 76 | 77 | ``` 78 | node object :person do 79 | field :name, :string 80 | field :age, :string 81 | end 82 | ``` 83 | 84 | This will create an object type, `:person`, as you might expect. An `:id` 85 | field is created for you automatically, and this field generates a global ID; 86 | an opaque string that's built using a global ID translator (by default a 87 | Base64 implementation). All of this is handled for you automatically by 88 | prefixing your object type definition with `"node "`. 89 | 90 | By default, type of `:id` field is `ID`. But you can pass custom type in `:id_type` attribute: 91 | 92 | ``` 93 | node interface id_type: :uuid do 94 | resolve_type fn 95 | ... 96 | end 97 | end 98 | 99 | node field id_type: :uuid do 100 | resolve fn 101 | ... 102 | end 103 | end 104 | 105 | node object :thing, id_type: :uuid do 106 | field :name, :string 107 | end 108 | ``` 109 | 110 | Or you can set it up globally via application config: 111 | ``` 112 | config Absinthe.Relay, 113 | node_id_type: :uuid 114 | ``` 115 | 116 | The raw, internal value is retrieved using `default_id_fetcher/2` which just 117 | pattern matches an `:id` field from the resolved object. If you need to 118 | extract/build an internal ID via another method, just provide a function as 119 | an `:id_fetcher` option. 120 | 121 | For instance, assuming your raw internal IDs were stored as `:_id`, you could 122 | configure your object like this: 123 | 124 | ``` 125 | node object :thing, id_fetcher: &my_custom_id_fetcher/2 do 126 | field :name, :string 127 | end 128 | ``` 129 | 130 | For instructions on how to change the underlying method of decoding/encoding 131 | a global ID, see `Absinthe.Relay.Node.IDTranslator`. 132 | 133 | ## Macros 134 | 135 | For more details on node-related macros, see 136 | `Absinthe.Relay.Node.Notation`. 137 | 138 | """ 139 | 140 | require Logger 141 | 142 | @type global_id :: binary 143 | 144 | # Middleware to handle a global id 145 | # parses the global ID before invoking it 146 | @doc false 147 | def resolve_with_global_id(%{state: :unresolved, arguments: %{id: global_id}} = res, _) do 148 | case Absinthe.Relay.Node.from_global_id(global_id, res.schema) do 149 | {:ok, result} -> 150 | %{res | arguments: result} 151 | 152 | error -> 153 | Absinthe.Resolution.put_result(res, error) 154 | end 155 | end 156 | 157 | def resolve_with_global_id(res, _) do 158 | res 159 | end 160 | 161 | @doc """ 162 | Parse a global ID, given a schema. 163 | 164 | To change the underlying method of decoding a global ID, 165 | see `Absinthe.Relay.Node.IDTranslator`. 166 | 167 | ## Examples 168 | 169 | For `nil`, pass-through: 170 | 171 | ``` 172 | iex> from_global_id(nil, Schema) 173 | {:ok, nil} 174 | ``` 175 | 176 | For a valid, existing type in `Schema`: 177 | 178 | ``` 179 | iex> from_global_id("UGVyc29uOjE=", Schema) 180 | {:ok, %{type: :person, id: "1"}} 181 | ``` 182 | 183 | For an invalid global ID value: 184 | 185 | ``` 186 | iex> from_global_id("GHNF", Schema) 187 | {:error, "Could not decode ID value `GHNF'"} 188 | ``` 189 | 190 | For a type that isn't in the schema: 191 | 192 | ``` 193 | iex> from_global_id("Tm9wZToxMjM=", Schema) 194 | {:error, "Unknown type `Nope'"} 195 | ``` 196 | 197 | For a type that is in the schema but isn't a node: 198 | 199 | ``` 200 | iex> from_global_id("Tm9wZToxMjM=", Schema) 201 | {:error, "Type `Item' is not a valid node type"} 202 | ``` 203 | """ 204 | @spec from_global_id(nil, Absinthe.Schema.t()) :: {:ok, nil} 205 | @spec from_global_id(global_id, Absinthe.Schema.t()) :: 206 | {:ok, %{type: atom, id: binary}} | {:error, binary} 207 | def from_global_id(nil, _schema) do 208 | {:ok, nil} 209 | end 210 | 211 | def from_global_id(global_id, schema) do 212 | case translate_global_id(schema, :from_global_id, [global_id]) do 213 | {:ok, type_name, id} -> 214 | do_from_global_id({type_name, id}, schema) 215 | 216 | {:error, err} -> 217 | {:error, err} 218 | end 219 | end 220 | 221 | defp do_from_global_id({type_name, id}, schema) do 222 | case schema.__absinthe_type__(type_name) do 223 | nil -> 224 | {:error, "Unknown type `#{type_name}'"} 225 | 226 | %{identifier: ident, interfaces: interfaces} -> 227 | if Enum.member?(List.wrap(interfaces), :node) do 228 | {:ok, %{type: ident, id: id}} 229 | else 230 | {:error, "Type `#{type_name}' is not a valid node type"} 231 | end 232 | end 233 | end 234 | 235 | @doc """ 236 | Generate a global ID given a node type name and an internal (non-global) ID given a schema. 237 | 238 | To change the underlying method of encoding a global ID, 239 | see `Absinthe.Relay.Node.IDTranslator`. 240 | 241 | ## Examples 242 | 243 | ``` 244 | iex> to_global_id("Person", "123") 245 | "UGVyc29uOjEyMw==" 246 | iex> to_global_id(:person, "123", SchemaWithPersonType) 247 | "UGVyc29uOjEyMw==" 248 | iex> to_global_id(:person, nil, SchemaWithPersonType) 249 | nil 250 | ``` 251 | """ 252 | # TODO: Return tuples in v1.5 253 | @spec to_global_id(atom | binary, integer | binary | nil, Absinthe.Schema.t() | nil) :: 254 | global_id | nil 255 | def to_global_id(node_type, source_id, schema \\ nil) 256 | 257 | def to_global_id(_node_type, nil, _schema) do 258 | nil 259 | end 260 | 261 | def to_global_id(node_type, source_id, schema) when is_binary(node_type) do 262 | case translate_global_id(schema, :to_global_id, [node_type, source_id]) do 263 | {:ok, global_id} -> 264 | global_id 265 | 266 | {:error, err} -> 267 | Logger.warn( 268 | "Failed to translate (#{inspect(node_type)}, #{inspect(source_id)}) to global ID with error: #{ 269 | err 270 | }" 271 | ) 272 | 273 | nil 274 | end 275 | end 276 | 277 | def to_global_id(node_type, source_id, schema) when is_atom(node_type) and not is_nil(schema) do 278 | case Absinthe.Schema.lookup_type(schema, node_type) do 279 | nil -> 280 | nil 281 | 282 | type -> 283 | to_global_id(type.name, source_id, schema) 284 | end 285 | end 286 | 287 | defp translate_global_id(schema, direction, args) do 288 | schema 289 | |> global_id_translator 290 | |> apply(direction, args ++ [schema]) 291 | end 292 | 293 | @non_relay_schema_error "Non Relay schema provided" 294 | @doc false 295 | # Returns an ID Translator from either the schema config, env config. 296 | # or a default Base64 implementation. 297 | def global_id_translator(nil) do 298 | Absinthe.Relay.Node.IDTranslator.Base64 299 | end 300 | 301 | def global_id_translator(schema) do 302 | from_schema = 303 | case Keyword.get(schema.__info__(:functions), :__absinthe_relay_global_id_translator__) do 304 | 0 -> 305 | apply(schema, :__absinthe_relay_global_id_translator__, []) 306 | 307 | nil -> 308 | raise ArgumentError, message: @non_relay_schema_error 309 | end 310 | 311 | from_env = 312 | Absinthe.Relay 313 | |> Application.get_env(schema, []) 314 | |> Keyword.get(:global_id_translator, nil) 315 | 316 | from_schema || from_env || Absinthe.Relay.Node.IDTranslator.Base64 317 | end 318 | 319 | @missing_internal_id_error "No source non-global ID value could be fetched from the source object" 320 | @doc false 321 | 322 | # The resolver for a global ID. If a type identifier instead of a type name 323 | # is used during field configuration, the type name needs to be looked up 324 | # during resolution. 325 | 326 | def global_id_resolver(%Absinthe.Resolution{state: :unresolved} = res, id_fetcher) do 327 | type = res.parent_type 328 | 329 | id_fetcher = id_fetcher || (&default_id_fetcher/2) 330 | 331 | result = 332 | case id_fetcher.(res.source, res) do 333 | nil -> 334 | report_fetch_id_error(type.name, res.source) 335 | 336 | internal_id -> 337 | {:ok, to_global_id(type.name, internal_id, res.schema)} 338 | end 339 | 340 | Absinthe.Resolution.put_result(res, result) 341 | end 342 | 343 | def global_id_resolver(identifier, nil) do 344 | global_id_resolver(identifier, &default_id_fetcher/2) 345 | end 346 | 347 | def global_id_resolver(identifier, id_fetcher) when is_atom(identifier) do 348 | fn _obj, info -> 349 | type = Absinthe.Schema.lookup_type(info.schema, identifier) 350 | 351 | case id_fetcher.(info.source, info) do 352 | nil -> 353 | report_fetch_id_error(type.name, info.source) 354 | 355 | internal_id -> 356 | {:ok, to_global_id(type.name, internal_id, info.schema)} 357 | end 358 | end 359 | end 360 | 361 | def global_id_resolver(type_name, id_fetcher) when is_binary(type_name) do 362 | fn _, info -> 363 | case id_fetcher.(info.source, info) do 364 | nil -> 365 | report_fetch_id_error(type_name, info.source) 366 | 367 | internal_id -> 368 | {:ok, to_global_id(type_name, internal_id, info.schema)} 369 | end 370 | end 371 | end 372 | 373 | # Reports a failure to fetch an ID 374 | @spec report_fetch_id_error(type_name :: String.t(), source :: any) :: {:error, String.t()} 375 | defp report_fetch_id_error(type_name, source) do 376 | Logger.warn(@missing_internal_id_error <> " (type #{type_name})") 377 | Logger.debug(inspect(source)) 378 | {:error, @missing_internal_id_error} 379 | end 380 | 381 | @doc """ 382 | The default ID fetcher used to retrieve raw, non-global IDs from values. 383 | 384 | * Matches `:id` out of the value. 385 | * If it's `nil`, it returns `nil` 386 | * If it's not nil, it coerces it to a binary using `Kernel.to_string/1` 387 | 388 | ## Examples 389 | 390 | ``` 391 | iex> default_id_fetcher(%{id: "foo"}) 392 | "foo" 393 | iex> default_id_fetcher(%{id: 123}) 394 | "123" 395 | iex> default_id_fetcher(%{id: nil}) 396 | nil 397 | iex> default_id_fetcher(%{nope: "no_id"}) 398 | nil 399 | ``` 400 | """ 401 | @spec default_id_fetcher(any, Absinthe.Resolution.t()) :: nil | binary 402 | def default_id_fetcher(%{id: id}, _info) when is_nil(id), do: nil 403 | def default_id_fetcher(%{id: id}, _info), do: id |> to_string 404 | def default_id_fetcher(_, _), do: nil 405 | end 406 | -------------------------------------------------------------------------------- /lib/absinthe/relay/node/parse_ids.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Node.ParseIDs do 2 | @behaviour Absinthe.Middleware 3 | 4 | @moduledoc """ 5 | Parse node (global) ID arguments before they are passed to a resolver, 6 | checking the arguments against acceptable types. 7 | 8 | For each argument: 9 | 10 | - If a single node type is provided, the node ID in the argument map will 11 | be replaced by the ID specific to your application. 12 | - If multiple node types are provided (as a list), the node ID in the 13 | argument map will be replaced by a map with the node ID specific to your 14 | application as `:id` and the parsed node type as `:type`. 15 | 16 | If a GraphQL `null` value for an ID is found, it will be passed through as 17 | `nil` in either case, since no type can be associated with the value. 18 | 19 | ## Examples 20 | 21 | Parse a node (global) ID argument `:item_id` as an `:item` type. This replaces 22 | the node ID in the argument map (key `:item_id`) with your 23 | application-specific ID. For example, `"123"`. 24 | 25 | ``` 26 | field :item, :item do 27 | arg :item_id, non_null(:id) 28 | 29 | middleware Absinthe.Relay.Node.ParseIDs, item_id: :item 30 | resolve &item_resolver/3 31 | end 32 | ``` 33 | 34 | Parse a node (global) ID argument `:interface_id` into one of multiple node 35 | types. This replaces the node ID in the argument map (key `:interface_id`) 36 | with map of the parsed node type and your application-specific ID. For 37 | example, `%{type: :thing, id: "123"}`. 38 | 39 | ``` 40 | field :foo, :foo do 41 | arg :interface_id, non_null(:id) 42 | 43 | middleware Absinthe.Relay.Node.ParseIDs, interface_id: [:item, :thing] 44 | resolve &foo_resolver/3 45 | end 46 | ``` 47 | 48 | Parse a nested structure of node (global) IDs. This behaves similarly to the 49 | examples above, but acts recursively when given a keyword list. 50 | 51 | ``` 52 | input_object :parent_input do 53 | field :id, non_null(:id) 54 | field :children, list_of(:child_input) 55 | field :child, non_null(:child_input) 56 | end 57 | 58 | input_object :child_input do 59 | field :id, non_null(:id) 60 | end 61 | 62 | mutation do 63 | payload field :update_parent do 64 | input do 65 | field :parent, :parent_input 66 | end 67 | 68 | output do 69 | field :parent, :parent 70 | end 71 | 72 | middleware Absinthe.Relay.Node.ParseIDs, parent: [ 73 | id: :parent, 74 | children: [id: :child], 75 | child: [id: :child] 76 | ] 77 | resolve &resolve_parent/2 78 | end 79 | end 80 | ``` 81 | 82 | As with any piece of middleware, this can configured schema-wide using the 83 | `middleware/3` function in your schema. In this example all top level 84 | query fields are made to support node IDs with the associated criteria in 85 | `@node_id_rules`: 86 | 87 | ``` 88 | defmodule MyApp.Schema do 89 | 90 | # Schema ... 91 | 92 | @node_id_rules [ 93 | item_id: :item, 94 | interface_id: [:item, :thing], 95 | ] 96 | def middleware(middleware, _, %Absinthe.Type.Object{identifier: :query}) do 97 | [{Absinthe.Relay.Node.ParseIDs, @node_id_rules} | middleware] 98 | end 99 | def middleware(middleware, _, _) do 100 | middleware 101 | end 102 | 103 | end 104 | ``` 105 | 106 | ### Using with Mutations 107 | 108 | Important: Remember that middleware is applied in order. If you're 109 | using `middleware/3` to apply this middleware to a mutation field 110 | (defined using the `Absinthe.Relay.Mutation` macros) _before_ the 111 | `Absinthe.Relay.Mutation` middleware, you need to include a wrapping 112 | top-level `:input`, since the argument won't be stripped out yet. 113 | 114 | So, this configuration defined _inside_ of a `payload field` block: 115 | 116 | ``` 117 | mutation do 118 | 119 | payload field :change_something do 120 | 121 | # ... 122 | middleware Absinthe.Relay.Node.ParseIDs, profile: [ 123 | user_id: :user 124 | ] 125 | 126 | end 127 | 128 | end 129 | ``` 130 | 131 | Needs to look like this if you put the `ParseIDs` middleware first: 132 | 133 | ``` 134 | def middleware(middleware, %Absinthe.Type.Field{identifier: :change_something}, _) do 135 | # Note the addition of the `input` level: 136 | [{Absinthe.Relay.Node.ParseIDs, input: [profile: [user_id: :user]]} | middleware] 137 | end 138 | def middleware(middleware, _, _) do 139 | middleware 140 | end 141 | ``` 142 | 143 | If, however, you do a bit more advanced surgery to the `middleware` 144 | list and insert `Absinthe.Relay.Node.ParseIDs` _after_ 145 | `Absinthe.Relay.Mutation`, you don't include the wrapping `:input`. 146 | 147 | ## Compatibility Note for Middleware Developers 148 | 149 | If you're defining a piece of middleware that modifies field 150 | arguments similar to `Absinthe.Relay.Mutation` does (stripping the 151 | outer `input` argument), you need to set the private 152 | `:__parse_ids_root` so that this middleware can find the root schema 153 | node used to apply its configuration. See `Absinthe.Relay.Mutation` 154 | for an example of setting the value, and the `find_schema_root!/2` 155 | function in this module for how it's used. 156 | """ 157 | 158 | alias __MODULE__.{Config, Rule} 159 | 160 | @typedoc """ 161 | The rules used to parse node ID arguments. 162 | 163 | ## Examples 164 | 165 | Declare `:item_id` as only valid with the `:item` node type: 166 | 167 | ``` 168 | [ 169 | item_id: :item 170 | ] 171 | ``` 172 | 173 | Declare `:item_id` be valid as either `:foo` or `:bar` types: 174 | 175 | ``` 176 | [ 177 | item_id: [:foo, :bar] 178 | ] 179 | ``` 180 | 181 | Note that using these two different forms will result in different argument 182 | values being passed for `:item_id` (the former, as a `binary`, the latter 183 | as a `map`). 184 | 185 | In the event that the ID is a `null`, it will be passed-through as `nil`. 186 | 187 | See the module documentation for more details. 188 | """ 189 | @type rules :: [{atom, atom | [atom]}] | %{atom => atom | [atom]} 190 | 191 | @type simple_result :: nil | binary 192 | @type full_result :: %{type: atom, id: simple_result} 193 | @type result :: full_result | simple_result 194 | 195 | @doc false 196 | @spec call(Absinthe.Resolution.t(), rules) :: Absinthe.Resolution.t() 197 | def call(%{state: :unresolved} = resolution, rules) do 198 | case parse(resolution.arguments, rules, resolution) do 199 | {:ok, parsed_args} -> 200 | %{resolution | arguments: parsed_args} 201 | 202 | err -> 203 | resolution 204 | |> Absinthe.Resolution.put_result(err) 205 | end 206 | end 207 | 208 | def call(res, _) do 209 | res 210 | end 211 | 212 | @doc false 213 | @spec parse(map, rules, Absinthe.Resolution.t()) :: {:ok, map} | {:error, [String.t()]} 214 | def parse(args, rules, resolution) do 215 | config = Config.parse!(rules) 216 | {root, error_editor} = find_schema_root!(resolution.definition.schema_node, resolution) 217 | 218 | case process(config, args, resolution, root, []) do 219 | {processed_args, []} -> 220 | {:ok, processed_args} 221 | 222 | {_, errors} -> 223 | {:error, Enum.map(errors, error_editor)} 224 | end 225 | end 226 | 227 | # To support middleware that may run earlier and strip away toplevel arguments (eg, `Absinthe.Relay.Mutation` stripping 228 | # away `input`), we check for a private value on the resolution to see how to find the root schema definition. 229 | @spec find_schema_root!(Absinthe.Type.Field.t(), Absinthe.Resolution.t()) :: 230 | {{Absinthe.Type.Field.t() | Absinthe.Type.Argument.t(), String.t()}, 231 | (String.t() -> String.t())} 232 | def find_schema_root!( 233 | %{ 234 | __private__: [ 235 | absinthe_relay: [ 236 | payload: {:fill, _}, 237 | input: {:fill, _} 238 | ] 239 | ] 240 | } = field, 241 | resolution 242 | ) do 243 | case Map.get(resolution.private, :__parse_ids_root) do 244 | nil -> 245 | {field, & &1} 246 | 247 | root_argument -> 248 | argument = 249 | Map.get(field.args, root_argument) || 250 | raise "Can't find ParseIDs schema root argument #{inspect(root_argument)}" 251 | 252 | field_error_prefix = error_prefix(field, resolution.adapter) 253 | argument_error_prefix = error_prefix(argument, resolution.adapter) 254 | 255 | {argument, 256 | &String.replace_leading( 257 | &1, 258 | field_error_prefix, 259 | field_error_prefix <> argument_error_prefix 260 | )} 261 | end 262 | end 263 | 264 | def find_schema_root!(field, _resolution) do 265 | {field, & &1} 266 | end 267 | 268 | # Process values based on the matching configuration rules 269 | @spec process(Config.node_t(), any, Absinthe.Resolution.t(), Absinthe.Type.t(), list) :: 270 | {any, list} 271 | defp process(%{children: children}, args, resolution, schema_node, errors) do 272 | Enum.reduce( 273 | children, 274 | {args, errors}, 275 | &reduce_namespace_child_values(&1, &2, resolution, schema_node) 276 | ) 277 | end 278 | 279 | defp process(%Rule{} = rule, arg_values, resolution, schema_node, errors) 280 | when is_list(arg_values) do 281 | {processed, errors} = 282 | Enum.reduce(arg_values, {[], errors}, fn element_value, {values, errors} -> 283 | {processed_element_value, errors} = 284 | process(rule, element_value, resolution, schema_node, errors) 285 | 286 | {[processed_element_value | values], errors} 287 | end) 288 | 289 | {Enum.reverse(processed), errors} 290 | end 291 | 292 | defp process(%Rule{} = rule, arg_value, resolution, _schema_node, errors) do 293 | with {:ok, node_id} <- Absinthe.Relay.Node.from_global_id(arg_value, resolution.schema), 294 | {:ok, node_id} <- check_result(node_id, rule, resolution) do 295 | {Rule.output(rule, node_id), errors} 296 | else 297 | {:error, message} -> 298 | {arg_value, [message | errors]} 299 | end 300 | end 301 | 302 | # Since the raw value for a child may be a list, we normalize the raw value with a `List.wrap/1`, process that list, 303 | # then return a single value or a list of values, as appropriate, with any errors that are collected. 304 | @spec reduce_namespace_child_values( 305 | Config.node_t(), 306 | {any, [String.t()]}, 307 | Absinthe.Resolution.t(), 308 | Absinthe.Type.t() 309 | ) :: {any, [String.t()]} 310 | defp reduce_namespace_child_values(child, {raw_values, errors}, resolution, schema_node) do 311 | raw_values 312 | |> List.wrap() 313 | |> Enum.reduce( 314 | {[], []}, 315 | &reduce_namespace_child_value_element(child, &1, &2, resolution, schema_node) 316 | ) 317 | |> case do 318 | {values, []} -> 319 | {format_child_value(raw_values, values), errors} 320 | 321 | {_, processed_errors} -> 322 | {raw_values, errors ++ processed_errors} 323 | end 324 | end 325 | 326 | # Process a single value for a child and collect that value with any associated errors 327 | @spec reduce_namespace_child_value_element( 328 | Config.node_t(), 329 | any, 330 | {[any], [String.t()]}, 331 | Absinthe.Resolution.t(), 332 | Absinthe.Type.t() 333 | ) :: {[any], [String.t()]} 334 | defp reduce_namespace_child_value_element( 335 | %{key: key} = child, 336 | raw_value, 337 | {processed_values, processed_errors}, 338 | resolution, 339 | schema_node 340 | ) do 341 | case Map.fetch(raw_value, key) do 342 | :error -> 343 | {[raw_value | processed_values], processed_errors} 344 | 345 | {:ok, raw_value_for_key} -> 346 | case find_child_schema_node(key, schema_node, resolution.schema) do 347 | nil -> 348 | {processed_values, ["Could not find schema_node for #{key}" | processed_errors]} 349 | 350 | child_schema_node -> 351 | {processed_value_for_key, child_errors} = 352 | process(child, raw_value_for_key, resolution, child_schema_node, []) 353 | 354 | child_errors = 355 | Enum.map(child_errors, &(error_prefix(child_schema_node, resolution.adapter) <> &1)) 356 | 357 | {[Map.put(raw_value, key, processed_value_for_key) | processed_values], 358 | processed_errors ++ child_errors} 359 | end 360 | end 361 | end 362 | 363 | # Return a value or a list of values based on how the original raw values were structured 364 | @spec format_child_value(a | [a], [a]) :: a | [a] | nil when a: any 365 | defp format_child_value(raw_values, values) when is_list(raw_values), 366 | do: values |> Enum.reverse() 367 | 368 | defp format_child_value(_, [value]), do: value 369 | 370 | defp format_child_value(nil, _), do: nil 371 | 372 | @spec find_child_schema_node( 373 | Absinthe.Type.identifier_t(), 374 | Absinthe.Type.Field.t() | Absinthe.Type.InputObject.t() | Absinthe.Type.Argument.t(), 375 | Absinthe.Schema.t() 376 | ) :: nil | Absinthe.Type.Argument.t() | Absinthe.Type.Field.t() 377 | defp find_child_schema_node(identifier, %Absinthe.Type.Field{} = field, schema) do 378 | case Absinthe.Schema.lookup_type(schema, field.type) do 379 | %Absinthe.Type.InputObject{} = return_type -> 380 | find_child_schema_node(identifier, return_type, schema) 381 | 382 | _ -> 383 | field.args[identifier] 384 | end 385 | end 386 | 387 | defp find_child_schema_node(identifier, %Absinthe.Type.InputObject{} = input_object, _schema) do 388 | input_object.fields[identifier] 389 | end 390 | 391 | defp find_child_schema_node(identifier, %Absinthe.Type.Argument{} = argument, schema) do 392 | find_child_schema_node(identifier, Absinthe.Schema.lookup_type(schema, argument.type), schema) 393 | end 394 | 395 | @spec check_result(nil, Rule.t(), Absinthe.Resolution.t()) :: {:ok, nil} 396 | @spec check_result(full_result, Rule.t(), Absinthe.Resolution.t()) :: 397 | {:ok, full_result} | {:error, String.t()} 398 | defp check_result(nil, _rule, _resolution) do 399 | {:ok, nil} 400 | end 401 | 402 | defp check_result(%{type: type} = result, %Rule{expected_types: types} = rule, resolution) do 403 | if type in types do 404 | {:ok, result} 405 | else 406 | type_name = 407 | result.type 408 | |> describe_type(resolution) 409 | 410 | expected_types = 411 | Enum.map(rule.expected_types, &describe_type(&1, resolution)) 412 | |> Enum.filter(&(&1 != nil)) 413 | 414 | {:error, ~s} 415 | end 416 | end 417 | 418 | defp describe_type(identifier, resolution) do 419 | with %{name: name} <- Absinthe.Schema.lookup_type(resolution.schema, identifier) do 420 | name 421 | end 422 | end 423 | 424 | defp error_prefix(%Absinthe.Type.Argument{} = node, adapter) do 425 | name = node.name |> adapter.to_external_name(:argument) 426 | ~s 427 | end 428 | 429 | defp error_prefix(%Absinthe.Type.Field{} = node, adapter) do 430 | name = node.name |> adapter.to_external_name(:field) 431 | ~s 432 | end 433 | end 434 | -------------------------------------------------------------------------------- /test/lib/absinthe/relay/mutation/modern_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Mutation.ModernTest do 2 | use Absinthe.Relay.Case, async: true 3 | 4 | defmodule SchemaWithInputAndOutput do 5 | use Absinthe.Schema 6 | use Absinthe.Relay.Schema, :modern 7 | 8 | query do 9 | end 10 | 11 | mutation do 12 | payload field(:simple_mutation) do 13 | input do 14 | field :input_data, :integer 15 | end 16 | 17 | output do 18 | field :result, :integer 19 | end 20 | 21 | resolve fn 22 | %{input_data: input_data}, _ -> 23 | {:ok, %{result: input_data * 2}} 24 | 25 | %{}, _ -> 26 | {:ok, %{result: 1}} 27 | end 28 | end 29 | end 30 | end 31 | 32 | describe "mutation field with input declaration" do 33 | @query """ 34 | mutation M { 35 | simpleMutation { 36 | result 37 | } 38 | } 39 | """ 40 | test "requires the input argument" do 41 | assert {:ok, 42 | %{ 43 | errors: [ 44 | %{ 45 | message: 46 | "In argument \"input\": Expected type \"SimpleMutationInput!\", found null." 47 | } 48 | ] 49 | }} = Absinthe.run(@query, SchemaWithInputAndOutput) 50 | end 51 | 52 | @query """ 53 | mutation M { 54 | simpleMutation(input: {input_data: 1}) { 55 | result 56 | } 57 | } 58 | """ 59 | @expected %{ 60 | data: %{ 61 | "simpleMutation" => %{ 62 | "result" => 2 63 | } 64 | } 65 | } 66 | test "resolves SchemaWithInputAndOutput" do 67 | assert {:ok, @expected} == Absinthe.run(@query, SchemaWithInputAndOutput) 68 | end 69 | end 70 | 71 | describe "__type introspection on SchemaWithInputAndOutput" do 72 | @query """ 73 | { 74 | __type(name: "SimpleMutationInput") { 75 | name 76 | kind 77 | inputFields { 78 | name 79 | type { 80 | name 81 | kind 82 | ofType { 83 | name 84 | kind 85 | } 86 | } 87 | } 88 | } 89 | } 90 | """ 91 | @expected %{ 92 | data: %{ 93 | "__type" => %{ 94 | "name" => "SimpleMutationInput", 95 | "kind" => "INPUT_OBJECT", 96 | "inputFields" => [ 97 | %{ 98 | "name" => "inputData", 99 | "type" => %{ 100 | "name" => "Int", 101 | "kind" => "SCALAR", 102 | "ofType" => nil 103 | } 104 | } 105 | ] 106 | } 107 | } 108 | } 109 | test "contains correct input type" do 110 | assert {:ok, @expected} = Absinthe.run(@query, SchemaWithInputAndOutput) 111 | end 112 | 113 | @query """ 114 | { 115 | __type(name: "SimpleMutationPayload") { 116 | name 117 | kind 118 | fields { 119 | name 120 | type { 121 | name 122 | kind 123 | ofType { 124 | name 125 | kind 126 | } 127 | } 128 | } 129 | } 130 | } 131 | """ 132 | @expected %{ 133 | data: %{ 134 | "__type" => %{ 135 | "name" => "SimpleMutationPayload", 136 | "kind" => "OBJECT", 137 | "fields" => [ 138 | %{ 139 | "name" => "result", 140 | "type" => %{ 141 | "name" => "Int", 142 | "kind" => "SCALAR", 143 | "ofType" => nil 144 | } 145 | } 146 | ] 147 | } 148 | } 149 | } 150 | test "contains correct payload type" do 151 | assert {:ok, @expected} == Absinthe.run(@query, SchemaWithInputAndOutput) 152 | end 153 | end 154 | 155 | describe "__schema introspection for SchemaWithInputAndOutput" do 156 | @query """ 157 | { 158 | __schema { 159 | mutationType { 160 | fields { 161 | name 162 | args { 163 | name 164 | type { 165 | name 166 | kind 167 | ofType { 168 | name 169 | kind 170 | } 171 | } 172 | } 173 | type { 174 | name 175 | kind 176 | } 177 | } 178 | } 179 | } 180 | } 181 | """ 182 | @expected %{ 183 | data: %{ 184 | "__schema" => %{ 185 | "mutationType" => %{ 186 | "fields" => [ 187 | %{ 188 | "name" => "simpleMutation", 189 | "args" => [ 190 | %{ 191 | "name" => "input", 192 | "type" => %{ 193 | "name" => nil, 194 | "kind" => "NON_NULL", 195 | "ofType" => %{ 196 | "name" => "SimpleMutationInput", 197 | "kind" => "INPUT_OBJECT" 198 | } 199 | } 200 | } 201 | ], 202 | "type" => %{ 203 | "name" => "SimpleMutationPayload", 204 | "kind" => "OBJECT" 205 | } 206 | } 207 | ] 208 | } 209 | } 210 | } 211 | } 212 | 213 | test "returns the correct field" do 214 | assert {:ok, @expected} == Absinthe.run(@query, SchemaWithInputAndOutput) 215 | end 216 | end 217 | 218 | defmodule SchemaWithInputAndOutputReversed do 219 | use Absinthe.Schema 220 | use Absinthe.Relay.Schema, :modern 221 | 222 | query do 223 | end 224 | 225 | mutation do 226 | payload field(:simple_mutation) do 227 | output do 228 | field :result, :integer 229 | end 230 | 231 | resolve fn 232 | %{input_data: input_data}, _ -> 233 | {:ok, %{result: input_data * 2}} 234 | 235 | %{}, _ -> 236 | {:ok, %{result: 1}} 237 | end 238 | 239 | input do 240 | field :input_data, :integer 241 | end 242 | end 243 | end 244 | end 245 | 246 | describe "mutation field with input declaration at end" do 247 | @query """ 248 | mutation M { 249 | simpleMutation { 250 | result 251 | } 252 | } 253 | """ 254 | test "requires the input argument" do 255 | assert {:ok, 256 | %{ 257 | errors: [ 258 | %{ 259 | message: 260 | "In argument \"input\": Expected type \"SimpleMutationInput!\", found null." 261 | } 262 | ] 263 | }} = Absinthe.run(@query, SchemaWithInputAndOutputReversed) 264 | end 265 | 266 | @query """ 267 | mutation M { 268 | simpleMutation(input: {input_data: 1}) { 269 | result 270 | } 271 | } 272 | """ 273 | @expected %{ 274 | data: %{ 275 | "simpleMutation" => %{ 276 | "result" => 2 277 | } 278 | } 279 | } 280 | test "resolves SchemaWithInputAndOutputReversed" do 281 | assert {:ok, @expected} == Absinthe.run(@query, SchemaWithInputAndOutputReversed) 282 | end 283 | end 284 | 285 | describe "__type introspection on SchemaWithInputAndOutputReversed" do 286 | @query """ 287 | { 288 | __type(name: "SimpleMutationInput") { 289 | name 290 | kind 291 | inputFields { 292 | name 293 | type { 294 | name 295 | kind 296 | ofType { 297 | name 298 | kind 299 | } 300 | } 301 | } 302 | } 303 | } 304 | """ 305 | @expected %{ 306 | data: %{ 307 | "__type" => %{ 308 | "name" => "SimpleMutationInput", 309 | "kind" => "INPUT_OBJECT", 310 | "inputFields" => [ 311 | %{ 312 | "name" => "inputData", 313 | "type" => %{ 314 | "name" => "Int", 315 | "kind" => "SCALAR", 316 | "ofType" => nil 317 | } 318 | } 319 | ] 320 | } 321 | } 322 | } 323 | test "contains correct input type" do 324 | assert {:ok, @expected} = Absinthe.run(@query, SchemaWithInputAndOutputReversed) 325 | end 326 | 327 | @query """ 328 | { 329 | __type(name: "SimpleMutationPayload") { 330 | name 331 | kind 332 | fields { 333 | name 334 | type { 335 | name 336 | kind 337 | ofType { 338 | name 339 | kind 340 | } 341 | } 342 | } 343 | } 344 | } 345 | """ 346 | @expected %{ 347 | data: %{ 348 | "__type" => %{ 349 | "name" => "SimpleMutationPayload", 350 | "kind" => "OBJECT", 351 | "fields" => [ 352 | %{ 353 | "name" => "result", 354 | "type" => %{ 355 | "name" => "Int", 356 | "kind" => "SCALAR", 357 | "ofType" => nil 358 | } 359 | } 360 | ] 361 | } 362 | } 363 | } 364 | test "contains correct payload type" do 365 | assert {:ok, @expected} == Absinthe.run(@query, SchemaWithInputAndOutputReversed) 366 | end 367 | end 368 | 369 | describe "__schema introspection for SchemaWithInputAndOutputReversed" do 370 | @query """ 371 | { 372 | __schema { 373 | mutationType { 374 | fields { 375 | name 376 | args { 377 | name 378 | type { 379 | name 380 | kind 381 | ofType { 382 | name 383 | kind 384 | } 385 | } 386 | } 387 | type { 388 | name 389 | kind 390 | } 391 | } 392 | } 393 | } 394 | } 395 | """ 396 | @expected %{ 397 | data: %{ 398 | "__schema" => %{ 399 | "mutationType" => %{ 400 | "fields" => [ 401 | %{ 402 | "name" => "simpleMutation", 403 | "args" => [ 404 | %{ 405 | "name" => "input", 406 | "type" => %{ 407 | "name" => nil, 408 | "kind" => "NON_NULL", 409 | "ofType" => %{ 410 | "name" => "SimpleMutationInput", 411 | "kind" => "INPUT_OBJECT" 412 | } 413 | } 414 | } 415 | ], 416 | "type" => %{ 417 | "name" => "SimpleMutationPayload", 418 | "kind" => "OBJECT" 419 | } 420 | } 421 | ] 422 | } 423 | } 424 | } 425 | } 426 | 427 | test "returns the correct field" do 428 | assert {:ok, @expected} == Absinthe.run(@query, SchemaWithInputAndOutputReversed) 429 | end 430 | end 431 | 432 | defmodule SchemaWithOutputButNoInput do 433 | use Absinthe.Schema 434 | use Absinthe.Relay.Schema, :modern 435 | 436 | query do 437 | end 438 | 439 | mutation do 440 | payload field(:simple_mutation) do 441 | output do 442 | field :result, :integer 443 | end 444 | 445 | resolve fn _, _ -> {:ok, %{result: 1}} end 446 | end 447 | end 448 | end 449 | 450 | describe "executing for SchemaWithOutputButNoInput" do 451 | @query """ 452 | mutation M { 453 | simpleMutation { 454 | result 455 | } 456 | } 457 | """ 458 | @expected %{ 459 | data: %{ 460 | "simpleMutation" => %{ 461 | "result" => 1 462 | } 463 | } 464 | } 465 | test "resolves as expected" do 466 | assert {:ok, @expected} == Absinthe.run(@query, SchemaWithOutputButNoInput) 467 | end 468 | end 469 | 470 | describe "__type introspection on SchemaWithOutputButNoInput" do 471 | @query """ 472 | { 473 | __type(name: "SimpleMutationInput") { 474 | name 475 | } 476 | } 477 | """ 478 | @expected %{ 479 | data: %{ 480 | "__type" => nil 481 | } 482 | } 483 | test "return nil for the input type" do 484 | assert {:ok, @expected} = Absinthe.run(@query, SchemaWithOutputButNoInput) 485 | end 486 | 487 | @query """ 488 | { 489 | __type(name: "SimpleMutationPayload") { 490 | name 491 | kind 492 | fields { 493 | name 494 | type { 495 | name 496 | kind 497 | ofType { 498 | name 499 | kind 500 | } 501 | } 502 | } 503 | } 504 | } 505 | """ 506 | @expected %{ 507 | data: %{ 508 | "__type" => %{ 509 | "name" => "SimpleMutationPayload", 510 | "kind" => "OBJECT", 511 | "fields" => [ 512 | %{ 513 | "name" => "result", 514 | "type" => %{ 515 | "name" => "Int", 516 | "kind" => "SCALAR", 517 | "ofType" => nil 518 | } 519 | } 520 | ] 521 | } 522 | } 523 | } 524 | test "contains correct payload" do 525 | assert {:ok, @expected} == Absinthe.run(@query, SchemaWithOutputButNoInput) 526 | end 527 | end 528 | 529 | describe "__schema introspection on SchemaWithOutputButNoInput" do 530 | @query """ 531 | { 532 | __schema { 533 | mutationType { 534 | fields { 535 | name 536 | args { 537 | name 538 | type { 539 | name 540 | kind 541 | ofType { 542 | name 543 | kind 544 | } 545 | } 546 | } 547 | type { 548 | name 549 | kind 550 | } 551 | } 552 | } 553 | } 554 | } 555 | """ 556 | @expected %{ 557 | data: %{ 558 | "__schema" => %{ 559 | "mutationType" => %{ 560 | "fields" => [ 561 | %{ 562 | "name" => "simpleMutation", 563 | "args" => [], 564 | "type" => %{ 565 | "name" => "SimpleMutationPayload", 566 | "kind" => "OBJECT" 567 | } 568 | } 569 | ] 570 | } 571 | } 572 | } 573 | } 574 | 575 | test "returns the correct field" do 576 | assert {:ok, @expected} == Absinthe.run(@query, SchemaWithOutputButNoInput) 577 | end 578 | end 579 | 580 | describe "an empty definition" do 581 | defmodule SchemaWithoutInputOrOutput do 582 | use Absinthe.Schema 583 | use Absinthe.Relay.Schema, :modern 584 | 585 | query do 586 | end 587 | 588 | mutation do 589 | payload(field :without_block, resolve: fn _, _ -> {:ok, %{}} end) 590 | 591 | payload field :with_block_and_attrs, resolve: fn _, _ -> {:ok, %{}} end do 592 | end 593 | 594 | payload field(:with_block) do 595 | resolve fn _, _ -> 596 | # Logic is there 597 | {:ok, %{}} 598 | end 599 | end 600 | end 601 | end 602 | 603 | test "input argument isn't present" do 604 | type = Absinthe.Schema.lookup_type(SchemaWithoutInputOrOutput, :mutation) 605 | 606 | for field <- Map.values(type.fields) do 607 | assert !Map.get(field.args, :input) 608 | end 609 | end 610 | 611 | test "output is present" do 612 | assert Absinthe.Schema.lookup_type(SchemaWithoutInputOrOutput, :without_block_payload) 613 | end 614 | end 615 | end 616 | -------------------------------------------------------------------------------- /lib/absinthe/relay/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Connection.Options do 2 | @moduledoc false 3 | 4 | alias Absinthe.Relay.Connection 5 | 6 | @typedoc false 7 | @type t :: %{ 8 | optional(:after) => nil | Connection.cursor(), 9 | optional(:before) => nil | Connection.cursor(), 10 | optional(:first) => nil | pos_integer(), 11 | optional(:last) => nil | pos_integer(), 12 | optional(any()) => any() 13 | } 14 | 15 | defstruct after: nil, before: nil, first: nil, last: nil 16 | end 17 | 18 | defmodule Absinthe.Relay.Connection do 19 | @moduledoc """ 20 | Support for paginated result sets. 21 | 22 | Define connection types that provide a standard mechanism for slicing and 23 | paginating result sets. 24 | 25 | For information about the connection model, see the Relay Cursor 26 | Connections Specification at 27 | https://facebook.github.io/relay/graphql/connections.htm. 28 | 29 | ## Connection 30 | 31 | Given an object type, eg: 32 | 33 | ``` 34 | object :pet do 35 | field :name, :string 36 | end 37 | ``` 38 | 39 | You can create a connection type to paginate them by: 40 | 41 | ``` 42 | connection node_type: :pet 43 | ``` 44 | 45 | This will automatically define two new types: `:pet_connection` and 46 | `:pet_edge`. 47 | 48 | We define a field that uses these types to paginate associated records 49 | by using `connection field`. Here, for instance, we support paginating a 50 | person's pets: 51 | 52 | ``` 53 | object :person do 54 | field :first_name, :string 55 | connection field :pets, node_type: :pet do 56 | resolve fn 57 | pagination_args, %{source: person} -> 58 | Absinthe.Relay.Connection.from_list( 59 | Enum.map(person.pet_ids, &pet_from_id(&1)), 60 | pagination_args 61 | ) 62 | end 63 | end 64 | end 65 | end 66 | ``` 67 | 68 | The `:pets` field is automatically set to return a `:pet_connection` type, 69 | and configured to accept the standard pagination arguments `after`, `before`, 70 | `first`, and `last`. We create the connection by using 71 | `Absinthe.Relay.Connection.from_list/2`, which takes a list and the pagination 72 | arguments passed to the resolver. 73 | 74 | It is possible to provide additional pagination arguments to a relay 75 | connection: 76 | 77 | ``` 78 | connection field :pets, node_type: :pet do 79 | arg :custom_arg, :custom 80 | # other args... 81 | resolve fn 82 | pagination_args_and_custom_args, %{source: person} -> 83 | # ... return {:ok, a_connection} 84 | end 85 | end 86 | ``` 87 | 88 | Note: `Absinthe.Relay.Connection.from_list/2` expects that the full list of 89 | records be materialized and provided. If you're using Ecto, you probably want 90 | to use `Absinthe.Relay.Connection.from_query/2` instead. 91 | 92 | Here's how you might request the names of the first `$petCount` pets a person 93 | owns: 94 | 95 | ``` 96 | query FindPets($personId: ID!, $petCount: Int!) { 97 | person(id: $personId) { 98 | pets(first: $petCount) { 99 | pageInfo { 100 | hasPreviousPage 101 | hasNextPage 102 | } 103 | edges { 104 | node { 105 | name 106 | } 107 | } 108 | } 109 | } 110 | } 111 | ``` 112 | 113 | `edges` here is the list of intermediary edge types (created for you 114 | automatically) that contain a field, `node`, that is the same `:node_type` you 115 | passed earlier (`:pet`). 116 | 117 | `pageInfo` is a field that contains information about the current 118 | view; the `startCursor`, `endCursor`, `hasPreviousPage`, and 119 | `hasNextPage` fields. 120 | 121 | ### Pagination Direction 122 | 123 | By default, connections will support bidirectional pagination, but you can 124 | also restrict the connection to just the `:forward` or `:backward` direction 125 | using the `:paginate` argument: 126 | 127 | ``` 128 | connection field :pets, node_type: :pet, paginate: :forward do 129 | ``` 130 | 131 | ### Customizing Types 132 | 133 | If you'd like to add additional fields to the generated connection and edge 134 | types, you can do that by providing a block to the `connection` macro, eg, 135 | here we add a field, `:twice_edges_count` to the connection type, and another, 136 | `:node_name_backwards`, to the edge type: 137 | 138 | ``` 139 | connection node_type: :pet do 140 | field :twice_edges_count, :integer do 141 | resolve fn 142 | _, %{source: conn} -> 143 | {:ok, length(conn.edges) * 2} 144 | end 145 | end 146 | edge do 147 | field :node_name_backwards, :string do 148 | resolve fn 149 | _, %{source: edge} -> 150 | {:ok, edge.node.name |> String.reverse} 151 | end 152 | end 153 | end 154 | end 155 | ``` 156 | 157 | Just remember that if you use the block form of `connection`, you must call 158 | the `edge` macro within the block. 159 | 160 | ### Customizing the node itself 161 | 162 | It's also possible to customize the way the `node` field of the 163 | connection's edge is resolved. This can, for example, be useful if 164 | you're working with a NoSQL database that returns relationships as 165 | lists of IDs. Consider the following example which paginates over 166 | the user's account array, but resolves each one of them 167 | independently. 168 | 169 | ``` 170 | object :account do 171 | field :id, non_null(:id) 172 | field :name, :string 173 | end 174 | 175 | connection node_type :account do 176 | edge do 177 | field :node, :account do 178 | resolve fn %{node: id}, _args, _info -> 179 | Account.find(id) 180 | end 181 | end 182 | end 183 | end 184 | 185 | object :user do 186 | field :name, string 187 | connection field :accounts, node_type: :account do 188 | resolve fn %{accounts: accounts}, _args, _info -> 189 | Absinthe.Relay.Connection.from_list(ids, args) 190 | end 191 | end 192 | end 193 | 194 | ``` 195 | 196 | This would resolve the connections into a list of the user's 197 | associated accounts, and then for each node find that particular 198 | account (preferrably batched). 199 | 200 | ## Creating Connections 201 | 202 | This module provides two functions that mirror similar JavaScript functions, 203 | `from_list/2,3` and `from_slice/2,3`. We also provide `from_query/2,3` if you 204 | have Ecto as a dependency for convenience. 205 | 206 | Use `from_list` when you have all items in a list that you're going to 207 | paginate over. 208 | 209 | Use `from_slice` when you have items for a particular request, and merely need 210 | a connection produced from these items. 211 | 212 | ### Supplying Edge Information 213 | 214 | In some cases you may wish to supply extra information about the edge 215 | so that it can be used in the schema. For example: 216 | 217 | ``` 218 | connection node_type: :user do 219 | edge do 220 | field :role, :string 221 | end 222 | end 223 | ``` 224 | 225 | To do this, pass `from_list` a list of 2-element tuples 226 | where the first element is the node and the second element 227 | either a map or a keyword list of the edge attributes. 228 | 229 | ``` 230 | [ 231 | {%{name: "Jim"}, role: "owner"}, 232 | {%{name: "Sari"}, role: "guest"}, 233 | {%{name: "Lee"}, %{role: "guest"}}, # This is OK, too 234 | ] 235 | |> Connection.from_list(args) 236 | ``` 237 | 238 | This is useful when using ecto to include relationship information 239 | on the edge itself via `from_query`: 240 | 241 | ``` 242 | # In a UserResolver module 243 | alias Absinthe.Relay 244 | 245 | def list_teams(args, %{context: %{current_user: user}}) do 246 | TeamAssignment 247 | |> from 248 | |> where([a], a.user_id == ^user.id) 249 | |> join(:left, [a], t in assoc(a, :team)) 250 | |> select([a,t], {t, map(a, [:role])}) 251 | |> Relay.Connection.from_query(&Repo.all/1, args) 252 | end 253 | ``` 254 | 255 | Be aware that if you pass `:node` in the arguments provided as the second 256 | element of the edge tuple, that value will be ignored and a warning logged. 257 | 258 | If you provide a `:cursor` argument, then your value will override 259 | the internally generated cursor. This may or may not be desirable. 260 | 261 | ## Schema Macros 262 | 263 | For more details on connection-related macros, see 264 | `Absinthe.Relay.Connection.Notation`. 265 | """ 266 | 267 | alias Absinthe.Relay.Connection.Options 268 | require Logger 269 | 270 | @cursor_prefix "arrayconnection:" 271 | 272 | @type t :: %{ 273 | edges: [edge], 274 | page_info: page_info 275 | } 276 | 277 | @typedoc """ 278 | An opaque pagination cursor 279 | 280 | Internally it has the base64 encoded structure: 281 | 282 | ``` 283 | #{@cursor_prefix}:$offset 284 | ``` 285 | """ 286 | @type cursor :: binary 287 | 288 | @type edge :: %{ 289 | node: term, 290 | cursor: cursor 291 | } 292 | 293 | @typedoc """ 294 | Offset from zero. 295 | 296 | Negative offsets are not supported. 297 | """ 298 | @type offset :: non_neg_integer 299 | @type limit :: non_neg_integer 300 | 301 | @type page_info :: %{ 302 | start_cursor: cursor, 303 | end_cursor: cursor, 304 | has_previous_page: boolean, 305 | has_next_page: boolean 306 | } 307 | 308 | @doc """ 309 | Get a connection object for a list of data. 310 | 311 | A simple function that accepts a list and connection arguments, and returns 312 | a connection object for use in GraphQL. 313 | 314 | The data given to it should constitute all data that further 315 | pagination requests may page over. As such, it may be very 316 | inefficient if you're pulling data from a database which could be 317 | used to more directly retrieve just the desired data. 318 | 319 | See also `from_query` and `from_slice`. 320 | 321 | ## Example 322 | ``` 323 | #in a resolver module 324 | @items ~w(foo bar baz) 325 | def list(args, _) do 326 | Connection.from_list(@items, args) 327 | end 328 | ``` 329 | """ 330 | @spec from_list(data :: list, args :: Options.t()) :: {:ok, t} | {:error, any} 331 | def from_list(data, args, opts \\ []) do 332 | with {:ok, direction, limit} <- limit(args, opts[:max]), 333 | {:ok, offset} <- offset(args) do 334 | count = length(data) 335 | 336 | {offset, limit} = 337 | case direction do 338 | :forward -> 339 | {offset || 0, limit} 340 | 341 | :backward -> 342 | end_offset = offset || count 343 | start_offset = max(end_offset - limit, 0) 344 | limit = if start_offset == 0, do: end_offset, else: limit 345 | {start_offset, limit} 346 | end 347 | 348 | opts = 349 | opts 350 | |> Keyword.put(:has_previous_page, offset > 0) 351 | |> Keyword.put(:has_next_page, count > offset + limit) 352 | 353 | data 354 | |> Enum.slice(offset, limit) 355 | |> from_slice(offset, opts) 356 | end 357 | end 358 | 359 | @type from_slice_opts :: [ 360 | has_previous_page: boolean, 361 | has_next_page: boolean 362 | ] 363 | 364 | @type pagination_direction :: :forward | :backward 365 | 366 | @doc """ 367 | Build a connection from slice 368 | 369 | This function assumes you have already retrieved precisely the number of items 370 | to be returned in this connection request. 371 | 372 | Often this function is used internally by other functions. 373 | 374 | ## Example 375 | 376 | This is basically how our `from_query/2` function works if we didn't need to 377 | worry about backwards pagination. 378 | ``` 379 | # In PostResolver module 380 | alias Absinthe.Relay 381 | 382 | def list(args, %{context: %{current_user: user}}) do 383 | {:ok, :forward, limit} = Connection.limit(args) 384 | {:ok, offset} = Connection.offset(args) 385 | 386 | Post 387 | |> where(author_id: ^user.id) 388 | |> limit(^limit) 389 | |> offset(^offset) 390 | |> Repo.all 391 | |> Relay.Connection.from_slice(offset) 392 | end 393 | ``` 394 | """ 395 | @spec from_slice(data :: list, offset :: offset) :: {:ok, t} 396 | @spec from_slice(data :: list, offset :: offset, opts :: from_slice_opts) :: {:ok, t} 397 | def from_slice(items, offset, opts \\ []) do 398 | {edges, first, last} = build_cursors(items, offset) 399 | 400 | page_info = %{ 401 | start_cursor: first, 402 | end_cursor: last, 403 | has_previous_page: Keyword.get(opts, :has_previous_page, false), 404 | has_next_page: Keyword.get(opts, :has_next_page, false) 405 | } 406 | 407 | {:ok, %{edges: edges, page_info: page_info}} 408 | end 409 | 410 | @doc """ 411 | Build a connection from an Ecto Query 412 | 413 | This will automatically set a limit and offset value on the Ecto 414 | query, and then run the query with whatever function is passed as 415 | the second argument. 416 | 417 | Notes: 418 | - Your query MUST have an `order_by` value. Offset does not make 419 | sense without one. 420 | - `last: N` must always be accompanied by either a `before:` argument 421 | to the query, 422 | or an explicit `count: ` option to the `from_query` call. 423 | Otherwise it is impossible to derive the required offset. 424 | 425 | ## Example 426 | ``` 427 | # In a PostResolver module 428 | alias Absinthe.Relay 429 | 430 | def list(args, %{context: %{current_user: user}}) do 431 | Post 432 | |> where(author_id: ^user.id) 433 | |> Relay.Connection.from_query(&Repo.all/1, args) 434 | end 435 | ``` 436 | """ 437 | 438 | @type from_query_opts :: 439 | [ 440 | count: non_neg_integer, 441 | max: pos_integer 442 | ] 443 | | from_slice_opts 444 | 445 | if Code.ensure_loaded?(Ecto) do 446 | @spec from_query(Ecto.Queryable.t(), (Ecto.Queryable.t() -> [term]), Options.t()) :: 447 | {:ok, map} | {:error, any} 448 | @spec from_query( 449 | Ecto.Queryable.t(), 450 | (Ecto.Queryable.t() -> [term]), 451 | Options.t(), 452 | from_query_opts 453 | ) :: {:ok, map} | {:error, any} 454 | def from_query(query, repo_fun, args, opts \\ []) do 455 | require Ecto.Query 456 | 457 | with {:ok, offset, limit} <- offset_and_limit_for_query(args, opts) do 458 | records = 459 | query 460 | |> Ecto.Query.limit(^(limit + 1)) 461 | |> Ecto.Query.offset(^offset) 462 | |> repo_fun.() 463 | 464 | opts = 465 | opts 466 | |> Keyword.put(:has_previous_page, offset > 0) 467 | |> Keyword.put(:has_next_page, length(records) > limit) 468 | 469 | from_slice(Enum.take(records, limit), offset, opts) 470 | end 471 | end 472 | else 473 | def from_query(_, _, _, _, _ \\ []) do 474 | raise ArgumentError, """ 475 | Ecto not Loaded! 476 | 477 | You cannot use this unless Ecto is also a dependency 478 | """ 479 | end 480 | end 481 | 482 | @doc false 483 | @spec offset_and_limit_for_query(Options.t(), from_query_opts) :: 484 | {:ok, offset, limit} | {:error, any} 485 | def offset_and_limit_for_query(args, opts) do 486 | with {:ok, direction, limit} <- limit(args, opts[:max]), 487 | {:ok, offset} <- offset(args) do 488 | case direction do 489 | :forward -> 490 | {:ok, offset || 0, limit} 491 | 492 | :backward -> 493 | case {offset, opts[:count]} do 494 | {nil, nil} -> 495 | {:error, 496 | "You must supply a count (total number of records) option if using `last` without `before`"} 497 | 498 | {nil, value} -> 499 | {:ok, max(value - limit, 0), limit} 500 | 501 | {value, _} -> 502 | start_offset = max(value - limit, 0) 503 | limit = if start_offset == 0, do: value, else: limit 504 | {:ok, start_offset, limit} 505 | end 506 | end 507 | end 508 | end 509 | 510 | @doc """ 511 | Same as `limit/1` with user provided upper bound. 512 | 513 | Often backend developers want to provide a maximum value above which no more 514 | records can be retrieved, no matter how many are asked for by the front end. 515 | 516 | This function provides that capability. For use with `from_list` or 517 | `from_query` use the `:max` option on those functions. 518 | """ 519 | @spec limit(args :: Options.t(), max :: pos_integer | nil) :: 520 | {:ok, pagination_direction, limit} | {:error, any} 521 | def limit(args, nil), do: limit(args) 522 | 523 | def limit(args, max) do 524 | with {:ok, direction, limit} <- limit(args) do 525 | {:ok, direction, min(max, limit)} 526 | end 527 | end 528 | 529 | @doc """ 530 | The direction and desired number of records in the pagination arguments. 531 | """ 532 | @spec limit(args :: Options.t()) :: {:ok, pagination_direction, limit} | {:error, any} 533 | def limit(%{first: first}) when not is_nil(first), do: {:ok, :forward, first} 534 | def limit(%{last: last}) when not is_nil(last), do: {:ok, :backward, last} 535 | def limit(_), do: {:error, "You must either supply `:first` or `:last`"} 536 | 537 | @doc """ 538 | Returns the offset for a page. 539 | 540 | The limit is required because if using backwards pagination the limit will be 541 | subtracted from the offset. 542 | 543 | If no offset is specified in the pagination arguments, this will return `nil`. 544 | """ 545 | @spec offset(args :: Options.t()) :: {:ok, offset | nil} | {:error, any} 546 | def offset(%{after: cursor}) when not is_nil(cursor) do 547 | with {:ok, offset} <- cursor_to_offset(cursor) do 548 | {:ok, offset + 1} 549 | else 550 | {:error, _} -> 551 | {:error, "Invalid cursor provided as `after` argument"} 552 | end 553 | end 554 | 555 | def offset(%{before: cursor}) when not is_nil(cursor) do 556 | with {:ok, offset} <- cursor_to_offset(cursor) do 557 | {:ok, max(offset, 0)} 558 | else 559 | {:error, _} -> 560 | {:error, "Invalid cursor provided as `before` argument"} 561 | end 562 | end 563 | 564 | def offset(_), do: {:ok, nil} 565 | 566 | defp build_cursors([], _offset), do: {[], nil, nil} 567 | 568 | defp build_cursors([item | items], offset) do 569 | offset = offset || 0 570 | first = offset_to_cursor(offset) 571 | edge = build_edge(item, first) 572 | {edges, _} = do_build_cursors(items, offset + 1, [edge], first) 573 | first = edges |> List.first() |> get_in([:cursor]) 574 | last = edges |> List.last() |> get_in([:cursor]) 575 | {edges, first, last} 576 | end 577 | 578 | defp do_build_cursors([], _, edges, last), do: {Enum.reverse(edges), last} 579 | 580 | defp do_build_cursors([item | rest], i, edges, _last) do 581 | cursor = offset_to_cursor(i) 582 | edge = build_edge(item, cursor) 583 | do_build_cursors(rest, i + 1, [edge | edges], cursor) 584 | end 585 | 586 | defp build_edge({item, args}, cursor) do 587 | args 588 | |> Enum.flat_map(fn 589 | {key, _} when key in [:node] -> 590 | Logger.warn("Ignoring additional #{key} provided on edge (overriding is not allowed)") 591 | [] 592 | 593 | {key, val} -> 594 | [{key, val}] 595 | end) 596 | |> Enum.into(build_edge(item, cursor)) 597 | end 598 | 599 | defp build_edge(item, cursor) do 600 | %{ 601 | node: item, 602 | cursor: cursor 603 | } 604 | end 605 | 606 | @doc """ 607 | Creates the cursor string from an offset. 608 | """ 609 | @spec offset_to_cursor(integer) :: binary 610 | def offset_to_cursor(offset) do 611 | [@cursor_prefix, to_string(offset)] 612 | |> IO.iodata_to_binary() 613 | |> Base.encode64() 614 | end 615 | 616 | @doc """ 617 | Rederives the offset from the cursor string. 618 | """ 619 | @spec cursor_to_offset(binary) :: {:ok, integer} | {:error, any} 620 | def cursor_to_offset(cursor) do 621 | with {:ok, @cursor_prefix <> raw} <- Base.decode64(cursor), 622 | {parsed, _} <- Integer.parse(raw) do 623 | {:ok, parsed} 624 | else 625 | _ -> {:error, "Invalid cursor"} 626 | end 627 | end 628 | end 629 | -------------------------------------------------------------------------------- /test/lib/absinthe/relay/node/parse_ids_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Absinthe.Relay.Node.ParseIDsTest do 2 | use Absinthe.Relay.Case, async: true 3 | 4 | defmodule Foo do 5 | defstruct [:id, :name] 6 | end 7 | 8 | defmodule Parent do 9 | defstruct [:id, :name, :children] 10 | end 11 | 12 | defmodule Child do 13 | defstruct [:id, :name] 14 | end 15 | 16 | defmodule CustomIDTranslator do 17 | @behaviour Absinthe.Relay.Node.IDTranslator 18 | 19 | @impl true 20 | def to_global_id(type_name, source_id, _schema) do 21 | {:ok, "#{type_name}:#{source_id}"} 22 | end 23 | 24 | @impl true 25 | def from_global_id(global_id, _schema) do 26 | case String.split(global_id, ":", parts: 2) do 27 | [type_name, source_id] -> 28 | {:ok, type_name, source_id} 29 | 30 | _ -> 31 | {:error, "Could not extract value from ID `#{inspect(global_id)}`"} 32 | end 33 | end 34 | end 35 | 36 | defmodule SchemaClassic do 37 | use Absinthe.Schema 38 | 39 | use Absinthe.Relay.Schema, 40 | flavor: :classic, 41 | global_id_translator: CustomIDTranslator 42 | 43 | alias Absinthe.Relay.Node.ParseIDsTest.Foo 44 | alias Absinthe.Relay.Node.ParseIDsTest.Parent 45 | alias Absinthe.Relay.Node.ParseIDsTest.Child 46 | 47 | @foos %{ 48 | "1" => %Foo{id: "1", name: "Foo 1"}, 49 | "2" => %Foo{id: "2", name: "Foo 2"} 50 | } 51 | 52 | node interface do 53 | resolve_type fn 54 | %Foo{}, _ -> 55 | :foo 56 | 57 | %Parent{}, _ -> 58 | :parent 59 | 60 | %Child{}, _ -> 61 | :child 62 | 63 | _, _ -> 64 | nil 65 | end 66 | end 67 | 68 | node object(:foo) do 69 | field :name, :string 70 | end 71 | 72 | node object(:other_foo, name: "FancyFoo") do 73 | field :name, :string 74 | end 75 | 76 | node object(:parent) do 77 | field :name, :string 78 | field :children, list_of(:child) 79 | field :child, :child 80 | 81 | field :child_by_id, :child do 82 | arg :id, :id 83 | middleware Absinthe.Relay.Node.ParseIDs, id: :child 84 | resolve &resolve_child_by_id/3 85 | end 86 | end 87 | 88 | node object(:child) do 89 | field :name, :string 90 | end 91 | 92 | input_object :parent_input do 93 | field :id, non_null(:id) 94 | field :children, list_of(:child_input) 95 | field :child, non_null(:child_input) 96 | end 97 | 98 | input_object :child_input do 99 | field :id, :id 100 | end 101 | 102 | query do 103 | node field do 104 | resolve fn args, _ -> 105 | {:ok, args} 106 | end 107 | end 108 | 109 | field :unauthorized, :foo do 110 | arg :foo_id, :id 111 | 112 | resolve fn _, _, _ -> 113 | {:error, "unauthorized"} 114 | end 115 | 116 | middleware Absinthe.Relay.Node.ParseIDs, foo_id: :foo 117 | end 118 | 119 | field :foo, :foo do 120 | arg :foo_id, :id 121 | arg :foobar_id, :id 122 | middleware Absinthe.Relay.Node.ParseIDs, foo_id: :foo 123 | middleware Absinthe.Relay.Node.ParseIDs, foobar_id: [:foo, :bar] 124 | resolve &resolve_foo/2 125 | end 126 | 127 | field :foos, list_of(:foo) do 128 | arg :foo_ids, list_of(:id) 129 | middleware Absinthe.Relay.Node.ParseIDs, foo_ids: :foo 130 | resolve &resolve_foos/2 131 | end 132 | end 133 | 134 | mutation do 135 | payload field(:update_parent) do 136 | input do 137 | field :parent, :parent_input 138 | end 139 | 140 | output do 141 | field :parent, :parent 142 | end 143 | 144 | resolve &resolve_parent/2 145 | end 146 | 147 | payload field(:update_parent_local_middleware) do 148 | input do 149 | field :parent, :parent_input 150 | end 151 | 152 | output do 153 | field :parent, :parent 154 | end 155 | 156 | middleware Absinthe.Relay.Node.ParseIDs, 157 | parent: [ 158 | id: :parent, 159 | children: [id: :child], 160 | child: [id: :child] 161 | ] 162 | 163 | resolve &resolve_parent/2 164 | end 165 | end 166 | 167 | defp resolve_foo(%{foo_id: id}, _) do 168 | {:ok, Map.get(@foos, id)} 169 | end 170 | 171 | defp resolve_foo(%{foobar_id: nil}, _) do 172 | {:ok, nil} 173 | end 174 | 175 | defp resolve_foo(%{foobar_id: %{id: id, type: :foo}}, _) do 176 | {:ok, Map.get(@foos, id)} 177 | end 178 | 179 | defp resolve_foos(%{foo_ids: ids}, _) do 180 | values = Enum.map(ids, &Map.get(@foos, &1)) 181 | {:ok, values} 182 | end 183 | 184 | defp resolve_parent(args, _) do 185 | {:ok, args |> to_parent_output} 186 | end 187 | 188 | defp resolve_child_by_id(%{children: children}, %{id: id}, _) do 189 | child = Enum.find(children, &(&1.id === id)) 190 | {:ok, child} 191 | end 192 | 193 | # This is just a utility that converts the input value into the 194 | # expected output value (which has non-null constraints). 195 | # 196 | # It doesn't have any value outside these tests! 197 | # 198 | defp to_parent_output(%{id: nil}) do 199 | nil 200 | end 201 | 202 | defp to_parent_output(values) when is_list(values) do 203 | for value <- values do 204 | value 205 | |> to_parent_output 206 | end 207 | end 208 | 209 | defp to_parent_output(%{} = args) do 210 | for {key, value} <- args, into: %{} do 211 | {key, value |> to_parent_output} 212 | end 213 | end 214 | 215 | defp to_parent_output(value) do 216 | value 217 | end 218 | 219 | @update_parent_ids { 220 | Absinthe.Relay.Node.ParseIDs, 221 | [ 222 | # Needs `input` because this is being inserted 223 | # before the mutation middleware. 224 | input: [ 225 | parent: [ 226 | id: :parent, 227 | children: [id: :child], 228 | child: [id: :child] 229 | ] 230 | ] 231 | ] 232 | } 233 | def middleware(middleware, %{identifier: :update_parent}, _) do 234 | [@update_parent_ids | middleware] 235 | end 236 | 237 | def middleware(middleware, _, _) do 238 | middleware 239 | end 240 | end 241 | 242 | defmodule SchemaModern do 243 | use Absinthe.Schema 244 | use Absinthe.Relay.Schema, :modern 245 | 246 | alias Absinthe.Relay.Node.ParseIDsTest.Parent 247 | 248 | node interface do 249 | resolve_type fn 250 | %Parent{}, _ -> 251 | :parent 252 | 253 | %Child{}, _ -> 254 | :child 255 | 256 | _, _ -> 257 | nil 258 | end 259 | end 260 | 261 | input_object :parent_input do 262 | field :id, non_null(:id) 263 | field :children, list_of(:child_input) 264 | field :child, :child_input 265 | end 266 | 267 | input_object :child_input do 268 | field :id, :id 269 | end 270 | 271 | node object(:child) do 272 | field :name, :string 273 | end 274 | 275 | node object(:parent) do 276 | field :name, :string 277 | field :children, list_of(:child) 278 | field :child, :child 279 | 280 | field :child_by_id, :child do 281 | arg :id, :id 282 | middleware Absinthe.Relay.Node.ParseIDs, id: :child 283 | resolve &resolve_child_by_id/3 284 | end 285 | end 286 | 287 | query do 288 | end 289 | 290 | mutation do 291 | payload field(:update_parent_local_middleware) do 292 | input do 293 | field :parent, :parent_input 294 | end 295 | 296 | output do 297 | field :parent, :parent 298 | end 299 | 300 | middleware Absinthe.Relay.Node.ParseIDs, 301 | parent: [ 302 | id: :parent, 303 | children: [id: :child], 304 | child: [id: :child] 305 | ] 306 | 307 | resolve &resolve_parent/2 308 | end 309 | end 310 | 311 | defp resolve_parent(args, _) do 312 | {:ok, args} 313 | end 314 | 315 | defp resolve_child_by_id(%{children: children}, %{id: id}, _) do 316 | child = Enum.find(children, &(&1.id === id)) 317 | {:ok, child} 318 | end 319 | end 320 | 321 | @foo1_id "Foo:1" 322 | @foo2_id "Foo:2" 323 | @parent1_id "Parent:1" 324 | @child1_id "Child:1" 325 | @child2_id "Child:2" 326 | @otherfoo1_id "FancyFoo:1" 327 | @modern_parent1_id Base.encode64(@parent1_id) 328 | @modern_child1_id Base.encode64(@child1_id) 329 | @modern_child2_id Base.encode64(@child2_id) 330 | 331 | describe "parses one id" do 332 | test "succeeds with a non-null value" do 333 | result = 334 | """ 335 | { 336 | foo(fooId: "#{@foo1_id}") { 337 | id 338 | name 339 | } 340 | } 341 | """ 342 | |> Absinthe.run(SchemaClassic) 343 | 344 | assert {:ok, %{data: %{"foo" => %{"name" => "Foo 1", "id" => @foo1_id}}}} == result 345 | end 346 | 347 | test "succeeds with a null value" do 348 | result = 349 | """ 350 | { 351 | foo(fooId: null) { 352 | id 353 | name 354 | } 355 | } 356 | """ 357 | |> Absinthe.run(SchemaClassic) 358 | 359 | assert {:ok, %{data: %{"foo" => nil}}} == result 360 | end 361 | end 362 | 363 | describe "parses a list of ids" do 364 | test "succeeds with a non-null value" do 365 | result = 366 | """ 367 | { 368 | foos(fooIds: ["#{@foo1_id}", "#{@foo2_id}"]) { id name } 369 | } 370 | """ 371 | |> Absinthe.run(SchemaClassic) 372 | 373 | assert {:ok, 374 | %{ 375 | data: %{ 376 | "foos" => [ 377 | %{"name" => "Foo 1", "id" => @foo1_id}, 378 | %{"name" => "Foo 2", "id" => @foo2_id} 379 | ] 380 | } 381 | }} == result 382 | end 383 | 384 | test "succeeds with a null value" do 385 | result = 386 | """ 387 | { 388 | foos(fooIds: [null, "#{@foo2_id}"]) { id name } 389 | } 390 | """ 391 | |> Absinthe.run(SchemaClassic) 392 | 393 | assert {:ok, 394 | %{ 395 | data: %{ 396 | "foos" => [ 397 | nil, 398 | %{"name" => "Foo 2", "id" => @foo2_id} 399 | ] 400 | } 401 | }} == result 402 | end 403 | end 404 | 405 | describe "parsing an id into one of multiple node types" do 406 | test "parses an non-null id into one of multiple node types" do 407 | result = 408 | """ 409 | { 410 | foo(foobarId: "#{@foo1_id}") { id name } 411 | } 412 | """ 413 | |> Absinthe.run(SchemaClassic) 414 | 415 | assert {:ok, %{data: %{"foo" => %{"name" => "Foo 1", "id" => @foo1_id}}}} == result 416 | end 417 | 418 | test "parses null" do 419 | result = 420 | """ 421 | { 422 | foo(foobarId: null) { id name } 423 | } 424 | """ 425 | |> Absinthe.run(SchemaClassic) 426 | 427 | assert {:ok, %{data: %{"foo" => nil}}} == result 428 | end 429 | end 430 | 431 | describe "parsing nested ids" do 432 | test "works with non-null values" do 433 | result = 434 | """ 435 | mutation Foobar { 436 | updateParent(input: { 437 | clientMutationId: "abc", 438 | parent: { 439 | id: "#{@parent1_id}", 440 | children: [{ id: "#{@child1_id}"}, {id: "#{@child2_id}"}], 441 | child: { id: "#{@child2_id}"} 442 | } 443 | }) { 444 | parent { 445 | id 446 | children { id } 447 | child { id } 448 | } 449 | } 450 | } 451 | """ 452 | |> Absinthe.run(SchemaClassic) 453 | 454 | expected_parent_data = %{ 455 | "parent" => %{ 456 | # The output re-converts everything to global_ids. 457 | "id" => @parent1_id, 458 | "children" => [%{"id" => @child1_id}, %{"id" => @child2_id}], 459 | "child" => %{ 460 | "id" => @child2_id 461 | } 462 | } 463 | } 464 | 465 | assert {:ok, %{data: %{"updateParent" => expected_parent_data}}} == result 466 | end 467 | 468 | test "works with null branch values" do 469 | result = 470 | """ 471 | mutation Foobar { 472 | updateParent(input: { 473 | clientMutationId: "abc", 474 | parent: null 475 | }) { 476 | parent { 477 | id 478 | children { id } 479 | child { id } 480 | } 481 | } 482 | } 483 | """ 484 | |> Absinthe.run(SchemaClassic) 485 | 486 | expected_parent_data = %{ 487 | "parent" => nil 488 | } 489 | 490 | assert {:ok, %{data: %{"updateParent" => expected_parent_data}}} == result 491 | end 492 | 493 | test "works with null leaf values" do 494 | result = 495 | """ 496 | mutation Foobar { 497 | updateParent(input: { 498 | clientMutationId: "abc", 499 | parent: { 500 | id: "#{@parent1_id}", 501 | children: [{ id: "#{@child1_id}" }, { id: null }], 502 | child: { id: null } 503 | } 504 | }) { 505 | parent { 506 | id 507 | children { id } 508 | child { id } 509 | } 510 | } 511 | } 512 | """ 513 | |> Absinthe.run(SchemaClassic) 514 | 515 | expected_parent_data = %{ 516 | "parent" => %{ 517 | # The output re-converts everything to global_ids. 518 | "id" => @parent1_id, 519 | "children" => [%{"id" => @child1_id}, nil], 520 | "child" => nil 521 | } 522 | } 523 | 524 | assert {:ok, %{data: %{"updateParent" => expected_parent_data}}} == result 525 | end 526 | end 527 | 528 | test "parses incorrect nested ids" do 529 | incorrect_id = @otherfoo1_id 530 | 531 | mutation = """ 532 | mutation Foobar { 533 | updateParent(input: { 534 | clientMutationId: "abc", 535 | parent: { 536 | id: "#{@parent1_id}", 537 | child: {id: "#{incorrect_id}"} 538 | } 539 | }) { 540 | parent { 541 | id 542 | child { id } 543 | } 544 | } 545 | } 546 | """ 547 | 548 | assert {:ok, result} = Absinthe.run(mutation, SchemaClassic) 549 | 550 | assert %{ 551 | data: %{"updateParent" => nil}, 552 | errors: [ 553 | %{ 554 | locations: [%{column: 5, line: 2}], 555 | message: 556 | ~s 557 | } 558 | ] 559 | } = result 560 | end 561 | 562 | test "doesn't run if already resolved" do 563 | result = 564 | """ 565 | { 566 | unauthorized(fooId: "unknown") { 567 | id 568 | } 569 | } 570 | """ 571 | |> Absinthe.run(SchemaClassic) 572 | 573 | assert {:ok, 574 | %{ 575 | data: %{"unauthorized" => nil}, 576 | errors: [ 577 | %{ 578 | locations: [%{column: 3, line: 2}], 579 | message: "unauthorized", 580 | path: ["unauthorized"] 581 | } 582 | ] 583 | }} = result 584 | end 585 | 586 | test "handles one incorrect id correctly on node field" do 587 | result = 588 | """ 589 | { 590 | node(id: "unknown") { 591 | id 592 | } 593 | } 594 | """ 595 | |> Absinthe.run(SchemaClassic) 596 | 597 | assert {:ok, 598 | %{ 599 | data: %{"node" => nil}, 600 | errors: [ 601 | %{ 602 | locations: [%{column: 3, line: 2}], 603 | message: "Could not extract value from ID `\"unknown\"`", 604 | path: ["node"] 605 | } 606 | ] 607 | }} = result 608 | end 609 | 610 | test "handles one incorrect id correctly" do 611 | incorrect_id = @otherfoo1_id 612 | 613 | result = 614 | """ 615 | { 616 | foo(fooId: "#{incorrect_id}") { 617 | id 618 | name 619 | } 620 | } 621 | """ 622 | |> Absinthe.run(SchemaClassic) 623 | 624 | assert { 625 | :ok, 626 | %{ 627 | data: %{}, 628 | errors: [ 629 | %{ 630 | message: 631 | ~s 632 | } 633 | ] 634 | } 635 | } = result 636 | end 637 | 638 | describe "parses nested ids with local middleware" do 639 | test "for classic schema" do 640 | result = 641 | """ 642 | mutation FoobarLocal { 643 | updateParentLocalMiddleware(input: { 644 | clientMutationId: "abc", 645 | parent: { 646 | id: "#{@parent1_id}", 647 | children: [{ id: "#{@child1_id}"}, {id: "#{@child2_id}"}, {id: null}], 648 | child: { id: "#{@child2_id}"} 649 | } 650 | }) { 651 | parent { 652 | id 653 | children { id } 654 | child { id } 655 | } 656 | } 657 | } 658 | """ 659 | |> Absinthe.run(SchemaClassic) 660 | 661 | expected_parent_data = %{ 662 | "parent" => %{ 663 | # The output re-converts everything to global_ids. 664 | "id" => @parent1_id, 665 | "children" => [%{"id" => @child1_id}, %{"id" => @child2_id}, nil], 666 | "child" => %{ 667 | "id" => @child2_id 668 | } 669 | } 670 | } 671 | 672 | assert {:ok, %{data: %{"updateParentLocalMiddleware" => expected_parent_data}}} == result 673 | end 674 | 675 | test "for modern schema" do 676 | result = 677 | """ 678 | mutation FoobarLocal { 679 | updateParentLocalMiddleware(input: { 680 | parent: { 681 | id: "#{@modern_parent1_id}", 682 | } 683 | }) { 684 | parent { 685 | id 686 | } 687 | } 688 | } 689 | """ 690 | |> Absinthe.run(SchemaModern) 691 | 692 | expected_parent_data = %{ 693 | "parent" => %{ 694 | "id" => @modern_parent1_id 695 | } 696 | } 697 | 698 | assert {:ok, %{data: %{"updateParentLocalMiddleware" => expected_parent_data}}} == result 699 | end 700 | end 701 | 702 | describe "ParseIDs middlware in both mutation and child field" do 703 | test "classic schema" do 704 | result = 705 | """ 706 | mutation Foobar { 707 | updateParent(input: { 708 | clientMutationId: "abc", 709 | parent: { 710 | id: "#{@parent1_id}", 711 | children: [{ id: "#{@child1_id}"}, {id: "#{@child2_id}"}], 712 | child: { id: "#{@child2_id}"} 713 | } 714 | }) { 715 | parent { 716 | id 717 | childById(id: "#{@child2_id}") { id } 718 | } 719 | } 720 | } 721 | """ 722 | |> Absinthe.run(SchemaClassic) 723 | 724 | expected_parent_data = %{ 725 | "parent" => %{ 726 | # The output re-converts everything to global_ids. 727 | "id" => @parent1_id, 728 | "childById" => %{ 729 | "id" => @child2_id 730 | } 731 | } 732 | } 733 | 734 | assert {:ok, %{data: %{"updateParent" => expected_parent_data}}} == result 735 | end 736 | 737 | test "modern schema" do 738 | result = 739 | """ 740 | mutation FoobarLocal { 741 | updateParentLocalMiddleware(input: { 742 | parent: { 743 | id: "#{@modern_parent1_id}", 744 | children: [{ id: "#{@modern_child1_id}"}, {id: "#{@modern_child2_id}"}], 745 | child: { id: "#{@modern_child1_id}"} 746 | }}) 747 | { 748 | parent { 749 | id 750 | childById(id: "#{@modern_child1_id}") { 751 | id 752 | } 753 | } 754 | } 755 | } 756 | """ 757 | |> Absinthe.run(SchemaModern) 758 | 759 | expected_parent_data = 760 | {:ok, 761 | %{ 762 | data: %{ 763 | "updateParentLocalMiddleware" => %{ 764 | "parent" => %{ 765 | # The output re-converts everything to global_ids. 766 | "id" => @modern_parent1_id, 767 | "childById" => %{ 768 | "id" => @modern_child1_id 769 | } 770 | } 771 | } 772 | } 773 | }} 774 | 775 | assert expected_parent_data == result 776 | end 777 | end 778 | end 779 | --------------------------------------------------------------------------------