diff --git a/.formatter.exs b/.formatter.exs index b44b2b9..7733843 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,6 +1,6 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + inputs: ["{mix,.formatter}.exs", "{config,lib,test,federation_compatibility}/**/*.{ex,exs}"], line_length: 120, import_deps: [:absinthe] ] diff --git a/.gitignore b/.gitignore index deb3ef4..49a7fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,25 @@ erl_crash.dump # Ignore package tarball (built via "mix hex.build"). absinthe_federation-*.tar +# Federation test artifacts +supergraph-compose.yaml +supergraph-config.yaml +supergraph.graphql +results.md + +# Local temporary Dockerfiles +federation_compatibility_dockerfile.local + +# Local text editor files .vscode + +# Local version config +.mise.toml .tool-versions* + +# Local OS-specific files .DS_Store + +# PLT files /priv/plts/*.plt /priv/plts/*.plt.hash \ No newline at end of file diff --git a/README.md b/README.md index e7fbe36..cf4d20a 100644 --- a/README.md +++ b/README.md @@ -162,16 +162,19 @@ defmodule MyApp.MySchema do + extend schema do + directive :link, -+ url: "https://specs.apollo.dev/federation/v2.0", ++ url: "https://specs.apollo.dev/federation/v2.3", + import: [ + "@key", + "@shareable", + "@provides", ++ "@requires", + "@external", + "@tag", + "@extends", + "@override", -+ "@inaccessible" ++ "@inaccessible", ++ "@composeDirective", ++ "@interfaceObject" + ] + end diff --git a/federation_compatibility/lib/products_web/schema.ex b/federation_compatibility/lib/products_web/schema.ex index 452ba4a..5c20ffb 100644 --- a/federation_compatibility/lib/products_web/schema.ex +++ b/federation_compatibility/lib/products_web/schema.ex @@ -40,7 +40,7 @@ defmodule ProductsWeb.Schema do directive :link, url: "https://divvypay.com/test/v2.4", import: ["@custom"] directive :link, - url: "https://specs.apollo.dev/federation/v2.1", + url: "https://specs.apollo.dev/federation/v2.3", import: [ "@extends", "@external", @@ -51,7 +51,8 @@ defmodule ProductsWeb.Schema do "@requires", "@shareable", "@tag", - "@composeDirective" + "@composeDirective", + "@interfaceObject" ] end @@ -256,6 +257,23 @@ defmodule ProductsWeb.Schema do end end + @desc """ + type Inventory @interfaceObject @key(fields: "id") { + id: ID! @external + deprecatedProducts: [DeprecatedProduct!]! + } + """ + object :inventory do + key_fields("id") + interface_object() + + field :id, non_null(:id), do: external() + + field :deprecated_products, non_null(list_of(non_null(:deprecated_product))) do + resolve &resolve_deprecated_products_for_inventory/3 + end + end + defp resolve_product(_parent, %{id: id}, _ctx) do {:ok, Enum.find(products(), &(&1.id == id))} end @@ -307,16 +325,8 @@ defmodule ProductsWeb.Schema do end end - defp resolve_deprecated_product_reference( - %{sku: "apollo-federation-v1", package: "@apollo/federation-v1"}, - _ctx - ) do - {:ok, - %DeprecatedProduct{ - sku: "apollo-federation-v1", - package: "@apollo/federation-v1", - reason: "Migrate to Federation V2" - }} + defp resolve_deprecated_product_reference(%{sku: sku}, _ctx) do + {:ok, Enum.find(deprecated_products(), &(&1.sku == sku))} end defp resolve_deprecated_product_reference(_args, _ctx) do @@ -339,6 +349,10 @@ defmodule ProductsWeb.Schema do {:ok, nil} end + defp resolve_deprecated_products_for_inventory(%{__typename: "Inventory"} = _parent, _args, _ctx) do + {:ok, deprecated_products()} + end + defp resolve_deprecated_product_created_by(_deprecated_product, _args, _ctx) do {:ok, List.first(users())} end @@ -363,6 +377,15 @@ defmodule ProductsWeb.Schema do } ] + defp deprecated_products(), + do: [ + %DeprecatedProduct{ + sku: "apollo-federation-v1", + package: "@apollo/federation-v1", + reason: "Migrate to Federation V2" + } + ] + defp product_research(), do: [ %ProductResearch{ diff --git a/federation_compatibility_dockerfile b/federation_compatibility_dockerfile index 8ed2b0d..477e45a 100644 --- a/federation_compatibility_dockerfile +++ b/federation_compatibility_dockerfile @@ -1,4 +1,7 @@ FROM elixir:1.14.2-alpine AS build +ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem /usr/local/share/ca-certificates/aws-rds.crt +ADD https://mobile.zscaler.net/downloads/zscaler2048_sha256.crt /usr/local/share/ca-certificates/zscaler.crt +RUN cat /usr/local/share/ca-certificates/*.crt >> /etc/ssl/certs/ca-certificates.crt WORKDIR / COPY . . @@ -28,6 +31,11 @@ RUN mix do compile, release # prepare release image FROM alpine:3.16 AS app +ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem /usr/local/share/ca-certificates/aws-rds.crt +ADD https://mobile.zscaler.net/downloads/zscaler2048_sha256.crt /usr/local/share/ca-certificates/zscaler.crt +RUN cat /usr/local/share/ca-certificates/*.crt >> /etc/ssl/certs/ca-certificates.crt + +RUN apk update RUN apk add --no-cache openssl libgcc libstdc++ ncurses-libs WORKDIR /app diff --git a/lib/absinthe/federation/notation.ex b/lib/absinthe/federation/notation.ex index 289e61c..9fcfaf2 100644 --- a/lib/absinthe/federation/notation.ex +++ b/lib/absinthe/federation/notation.ex @@ -277,6 +277,52 @@ defmodule Absinthe.Federation.Notation do end end + @doc """ + Adds the `@interfaceObject` directive to the field which indicates that the + object definition serves as an abstraction of another subgraph's entity + interface. This abstraction enables a subgraph to automatically contribute + fields to all entities that implement a particular entity interface. + + During composition, the fields of every `@interfaceObject` are added both to + their corresponding interface definition and to all entity types that + implement that interface. + + More information can be found on: + https://www.apollographql.com/docs/federation/federated-types/interfaces + + ## Example + + object :media do + key_fields("id") + interface_object() + + field :id, non_null(:id), do: external() + field :reviews, non_null(list_of(non_null(:review))) + end + + object :review do + field :score, non_null(:integer) + end + + + ## SDL Output + + type Media @interfaceObject @key(fields: "id") { + id: ID! @external + reviews: [Review!]! + } + + type Review { + score: Int! + } + + """ + defmacro interface_object() do + quote do + meta :interface_object, true + end + end + @doc """ The `@tag` directive indicates whether to include or exclude the field/type from your contract schema. @@ -309,6 +355,9 @@ defmodule Absinthe.Federation.Notation do The `@link` directive links definitions from an external specification to this schema. Every Federation 2 subgraph uses the `@link` directive to import the other federation-specific directives. + **NOTE:** If you're using Absinthe v1.7.1 or later, instead of using this macro, it's preferred to use the + `extend schema` method you can find in the [README](README.md#federation-v2). + ## Example link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@shareable"]) diff --git a/lib/absinthe/federation/schema/phase/add_federated_directives.ex b/lib/absinthe/federation/schema/phase/add_federated_directives.ex index dbd9453..dfa94dc 100644 --- a/lib/absinthe/federation/schema/phase/add_federated_directives.ex +++ b/lib/absinthe/federation/schema/phase/add_federated_directives.ex @@ -33,6 +33,7 @@ defmodule Absinthe.Federation.Schema.Phase.AddFederatedDirectives do |> maybe_add_shareable_directive(meta) |> maybe_add_override_directive(meta) |> maybe_add_inaccessible_directive(meta) + |> maybe_add_interface_object_directive(meta) |> maybe_add_tag_directive(meta) end @@ -107,6 +108,14 @@ defmodule Absinthe.Federation.Schema.Phase.AddFederatedDirectives do defp maybe_add_inaccessible_directive(node, _meta), do: node + defp maybe_add_interface_object_directive(node, %{interface_object: true, absinthe_adapter: adapter}) do + directive = Directive.build("interface_object", adapter) + + add_directive(node, directive) + end + + defp maybe_add_interface_object_directive(node, _meta), do: node + defp maybe_add_tag_directive(node, %{tag: name, absinthe_adapter: adapter}) do directive = Directive.build("tag", adapter, name: name) diff --git a/lib/absinthe/federation/schema/prototype/federated_directives.ex b/lib/absinthe/federation/schema/prototype/federated_directives.ex index 947f632..48bc813 100644 --- a/lib/absinthe/federation/schema/prototype/federated_directives.ex +++ b/lib/absinthe/federation/schema/prototype/federated_directives.ex @@ -118,6 +118,15 @@ defmodule Absinthe.Federation.Schema.Prototype.FederatedDirectives do ] end + @desc """ + Indicates that an object definition serves as an abstraction of another subgraph's entity interface. + This abstraction enables a subgraph to automatically contribute fields to all entities that implement + a particular entity interface. + """ + directive :interface_object do + on [:object] + end + @desc """ The `@tag` directive indicates whether to include or exclude the field/type from your contract schema. """ diff --git a/test/absinthe/federation/notation_test.exs b/test/absinthe/federation/notation_test.exs index 32dbe2e..dde2e36 100644 --- a/test/absinthe/federation/notation_test.exs +++ b/test/absinthe/federation/notation_test.exs @@ -162,5 +162,37 @@ defmodule Absinthe.Federation.NotationTest do assert sdl =~ ~s{directive @custom on SCHEMA} assert sdl =~ ~s{directive @other on OBJECT} end + + test "schema with an interfaceObject is valid" do + defmodule InterfaceObjectSchema do + use Absinthe.Schema + use Absinthe.Federation.Schema + + extend schema do + directive :link, url: "https://specs.apollo.dev/federation/v2.3", import: ["@interfaceObject", "@key"] + end + + query do + field :hello, :media + end + + object :media do + key_fields("id") + interface_object() + + field :id, non_null(:id), do: external() + field :reviews, non_null(list_of(non_null(:review))) + end + + object :review do + field :score, non_null(:integer) + end + end + + sdl = Absinthe.Schema.to_sdl(InterfaceObjectSchema) + + assert sdl =~ ~s{import: ["@interfaceObject", "@key"])} + assert sdl =~ ~s{type Media @interfaceObject @key(fields: "id")} + end end end