Repository: mirumee/ariadne
Pull request #1060 (2023-03-22 15:32:59)

feat: add support for @interfaceObject directive
### Context
Apollo release a new version of Federation, version 2.3. Part of this release is the support for the `@interfaceObject` directive, which will allow users to extend/reference Entities from other subgraphs that are interfaces. You can read more about `@interfaceObject` directive here: https://github.com/mirumee/ariadne/issues/1032

This PR is an initial step into adding support for newer spec versions (`v2.1`, `v2.2` and `v2.3`), but the changes are very minimal so we unlock the work we have in progress regarding `Interface Entities`.

### Description

* This PR only adds definitions for all the newer spec versions (`v2.1`, `v2.2` and `v2.3`), but it doesn't add functionality for some of the features part of these new spec versions (for example `@composeDirective`), so it will unblock our migration from `Federation v1` to `Federation v2.3`

### Next steps

The next iterations for this work will be to add support for some more features part of the new specs. I have some of the work in progress, but it's not near being done.

Some of the features that will be covered in following PRs:
* Support for `@composeDirective` (Currently, we [remove](https://github.com/mirumee/ariadne/blob/master/ariadne/contrib/federation/schema.py#L86) all the custom directives, we want to keep directives defined with `@composeDirective`)
* Validation for directives like `@composeDirective` and `@interfaceObject`, to ensure we are using them with the right spec version
* Support for namespaces
```GraphQL
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", as: "fed2", import: ["@inaccessible", "@tag"])
            
type Product @fed2__key(fields: "upc") {
    upc: String!
  }
  
  type Query {
    product: Product
  }
```
* Renamed imports
```GraphQL
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: [{ name: "@key", as: "@myKey" }, { name: "@shareable" }, "@provides","@inaccessible"])

type Product @myKey(fields: "id") {
    id: ID!
    sku: String @shareable
}
```

rafalp: Can you update the PR to include changelog entry for your changes?
moonflare: > Can you update the PR to include changelog entry for your changes?

Thanks @rafalp. Added.
Please let me know if there's anything else needed 🙏🏻 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75796d7..1b3e5c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
 - Added `InputType` for setting Python representations of GraphQL Input types
 - Added support for passing `Enum` types directly to `make_executable_schema`
 - Added `convert_names_case` option to `make_federated_schema`.
+- Added support for the `@interfaceObject` directive in Apollo Federation.
 
 
 ## 0.18.1 (2023-02-22)
diff --git a/ariadne/contrib/federation/definitions/fed1_0.graphql b/ariadne/contrib/federation/definitions/fed1_0.graphql
new file mode 100644
index 0000000..8925765
--- /dev/null
+++ b/ariadne/contrib/federation/definitions/fed1_0.graphql
@@ -0,0 +1,6 @@
+directive @key(fields: _FieldSet!) repeatable on OBJECT | INTERFACE
+directive @requires(fields: _FieldSet!) on FIELD_DEFINITION
+directive @provides(fields: _FieldSet!) on FIELD_DEFINITION
+directive @external on FIELD_DEFINITION
+directive @extends on OBJECT | INTERFACE
+scalar _FieldSet
diff --git a/ariadne/contrib/federation/definitions/fed2_0.graphql b/ariadne/contrib/federation/definitions/fed2_0.graphql
new file mode 100644
index 0000000..4ece177
--- /dev/null
+++ b/ariadne/contrib/federation/definitions/fed2_0.graphql
@@ -0,0 +1,46 @@
+#
+# https://specs.apollo.dev/federation/v2.0/federation-v2.0.graphql
+#
+
+directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+directive @requires(fields: FieldSet!) on FIELD_DEFINITION
+directive @provides(fields: FieldSet!) on FIELD_DEFINITION
+directive @external on OBJECT | FIELD_DEFINITION
+directive @shareable on FIELD_DEFINITION | OBJECT
+directive @extends on OBJECT | INTERFACE
+directive @override(from: String!) on FIELD_DEFINITION
+directive @inaccessible on
+    | FIELD_DEFINITION
+    | OBJECT
+    | INTERFACE
+    | UNION
+    | ENUM
+    | ENUM_VALUE
+    | SCALAR
+    | INPUT_OBJECT
+    | INPUT_FIELD_DEFINITION
+    | ARGUMENT_DEFINITION
+directive @tag(name: String!) repeatable on
+    | FIELD_DEFINITION
+    | INTERFACE
+    | OBJECT
+    | UNION
+    | ARGUMENT_DEFINITION
+    | SCALAR
+    | ENUM
+    | ENUM_VALUE
+    | INPUT_OBJECT
+    | INPUT_FIELD_DEFINITION
+scalar FieldSet
+
+#
+# https://specs.apollo.dev/link/v1.0/link-v1.0.graphql
+#
+
+directive @link(
+    url: String!,
+    as: String,
+    import: [Import])
+repeatable on SCHEMA
+
+scalar Import
diff --git a/ariadne/contrib/federation/definitions/fed2_1.graphql b/ariadne/contrib/federation/definitions/fed2_1.graphql
new file mode 100644
index 0000000..5d39d31
--- /dev/null
+++ b/ariadne/contrib/federation/definitions/fed2_1.graphql
@@ -0,0 +1,52 @@
+#
+# https://specs.apollo.dev/federation/v2.0/federation-v2.0.graphql
+#
+
+directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+directive @requires(fields: FieldSet!) on FIELD_DEFINITION
+directive @provides(fields: FieldSet!) on FIELD_DEFINITION
+directive @external on OBJECT | FIELD_DEFINITION
+directive @shareable on FIELD_DEFINITION | OBJECT
+directive @extends on OBJECT | INTERFACE
+directive @override(from: String!) on FIELD_DEFINITION
+directive @inaccessible on
+    | FIELD_DEFINITION
+    | OBJECT
+    | INTERFACE
+    | UNION
+    | ENUM
+    | ENUM_VALUE
+    | SCALAR
+    | INPUT_OBJECT
+    | INPUT_FIELD_DEFINITION
+    | ARGUMENT_DEFINITION
+directive @tag(name: String!) repeatable on
+    | FIELD_DEFINITION
+    | INTERFACE
+    | OBJECT
+    | UNION
+    | ARGUMENT_DEFINITION
+    | SCALAR
+    | ENUM
+    | ENUM_VALUE
+    | INPUT_OBJECT
+    | INPUT_FIELD_DEFINITION
+scalar FieldSet
+
+#
+# federation-v2.1
+#
+
+directive @composeDirective(name: String!) repeatable on SCHEMA
+
+#
+# https://specs.apollo.dev/link/v1.0/link-v1.0.graphql
+#
+
+directive @link(
+    url: String!,
+    as: String,
+    import: [Import])
+repeatable on SCHEMA
+
+scalar Import
diff --git a/ariadne/contrib/federation/definitions/fed2_2.graphql b/ariadne/contrib/federation/definitions/fed2_2.graphql
new file mode 100644
index 0000000..de346da
--- /dev/null
+++ b/ariadne/contrib/federation/definitions/fed2_2.graphql
@@ -0,0 +1,57 @@
+#
+# https://specs.apollo.dev/federation/v2.0/federation-v2.0.graphql
+#
+
+directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+directive @requires(fields: FieldSet!) on FIELD_DEFINITION
+directive @provides(fields: FieldSet!) on FIELD_DEFINITION
+directive @external on OBJECT | FIELD_DEFINITION
+directive @extends on OBJECT | INTERFACE
+directive @override(from: String!) on FIELD_DEFINITION
+directive @inaccessible on
+    | FIELD_DEFINITION
+    | OBJECT
+    | INTERFACE
+    | UNION
+    | ENUM
+    | ENUM_VALUE
+    | SCALAR
+    | INPUT_OBJECT
+    | INPUT_FIELD_DEFINITION
+    | ARGUMENT_DEFINITION
+directive @tag(name: String!) repeatable on
+    | FIELD_DEFINITION
+    | INTERFACE
+    | OBJECT
+    | UNION
+    | ARGUMENT_DEFINITION
+    | SCALAR
+    | ENUM
+    | ENUM_VALUE
+    | INPUT_OBJECT
+    | INPUT_FIELD_DEFINITION
+scalar FieldSet
+
+#
+# federation-v2.1
+#
+
+directive @composeDirective(name: String!) repeatable on SCHEMA
+
+#
+# https://specs.apollo.dev/link/v1.0/link-v1.0.graphql
+#
+
+directive @link(
+    url: String!,
+    as: String,
+    import: [Import])
+repeatable on SCHEMA
+
+scalar Import
+
+#
+# federation-v2.2
+#
+
+directive @shareable repeatable on FIELD_DEFINITION | OBJECT
diff --git a/ariadne/contrib/federation/definitions/fed2_3.graphql b/ariadne/contrib/federation/definitions/fed2_3.graphql
new file mode 100644
index 0000000..4466f96
--- /dev/null
+++ b/ariadne/contrib/federation/definitions/fed2_3.graphql
@@ -0,0 +1,63 @@
+#
+# https://specs.apollo.dev/federation/v2.0/federation-v2.0.graphql
+#
+
+directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+directive @requires(fields: FieldSet!) on FIELD_DEFINITION
+directive @provides(fields: FieldSet!) on FIELD_DEFINITION
+directive @external on OBJECT | FIELD_DEFINITION
+directive @extends on OBJECT | INTERFACE
+directive @override(from: String!) on FIELD_DEFINITION
+directive @inaccessible on
+    | FIELD_DEFINITION
+    | OBJECT
+    | INTERFACE
+    | UNION
+    | ENUM
+    | ENUM_VALUE
+    | SCALAR
+    | INPUT_OBJECT
+    | INPUT_FIELD_DEFINITION
+    | ARGUMENT_DEFINITION
+directive @tag(name: String!) repeatable on
+    | FIELD_DEFINITION
+    | INTERFACE
+    | OBJECT
+    | UNION
+    | ARGUMENT_DEFINITION
+    | SCALAR
+    | ENUM
+    | ENUM_VALUE
+    | INPUT_OBJECT
+    | INPUT_FIELD_DEFINITION
+scalar FieldSet
+
+#
+# federation-v2.1
+#
+
+directive @composeDirective(name: String!) repeatable on SCHEMA
+
+#
+# https://specs.apollo.dev/link/v1.0/link-v1.0.graphql
+#
+
+directive @link(
+    url: String!,
+    as: String,
+    import: [Import])
+repeatable on SCHEMA
+
+scalar Import
+
+#
+# federation-v2.2
+#
+
+directive @shareable repeatable on FIELD_DEFINITION | OBJECT
+
+#
+# federation-v2.3
+#
+
+directive @interfaceObject on OBJECT
diff --git a/ariadne/contrib/federation/schema.py b/ariadne/contrib/federation/schema.py
index 6ec9e96..e9b9753 100644
--- a/ariadne/contrib/federation/schema.py
+++ b/ariadne/contrib/federation/schema.py
@@ -1,4 +1,5 @@
 import re
+import os
 from typing import Dict, List, Optional, Type, Union, cast
 
 from graphql import extend_schema, parse
@@ -17,6 +18,7 @@ from ...executable_schema import (
 )
 from ...schema_names import SchemaNameConverter
 from ...schema_visitor import SchemaDirectiveVisitor
+from ...load_schema import load_schema_from_path
 from .utils import get_entity_types, purge_schema_directives, resolve_entities
 
 base_federation_service_type_defs = """
@@ -29,27 +31,6 @@ base_federation_service_type_defs = """
     {type_token} Query {{
         _service: _Service!
     }}
-
-    directive @external on FIELD_DEFINITION
-	directive @requires(fields: String!) on FIELD_DEFINITION
-	directive @provides(fields: String!) on FIELD_DEFINITION
-	directive @extends on OBJECT | INTERFACE
-"""
-
-# fed 1 typedefs; only @key differs from the rest
-federation_one_service_type_defs = """
-    directive @key(fields: String!) repeatable on OBJECT | INTERFACE
-    directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION
-"""
-
-# fed 2 typedefs; adds the new directives, although they are gateway-driven, not subgraph-driven
-federation_two_service_type_defs = """
-    directive @key(fields: String!, resolvable: Boolean) repeatable on OBJECT | INTERFACE
-	directive @link(import: [String!], url: String!) repeatable on SCHEMA
-	directive @shareable on OBJECT | FIELD_DEFINITION
-    directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION
-	directive @override(from: String!) on FIELD_DEFINITION
-	directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION
 """
 
 federation_entity_type_defs = """
@@ -94,10 +75,29 @@ def make_federated_schema(
     link = re.search(
         r"(?<=@link).*?url:.*?\"(.*?)\".?[^)]+?", sdl, re.MULTILINE | re.DOTALL
     )  # use regex to parse if it's fed 1 or fed 2; adds dedicated typedefs per spec
+    dirname = os.path.dirname(__file__)
     if link and link.group(1) == "https://specs.apollo.dev/federation/v2.0":
-        tdl.append(federation_two_service_type_defs)
+        definitions = load_schema_from_path(
+            os.path.join(dirname, "./definitions/fed2_0.graphql")
+        )
+    elif link and link.group(1) == "https://specs.apollo.dev/federation/v2.1":
+        definitions = load_schema_from_path(
+            os.path.join(dirname, "./definitions/fed2_1.graphql")
+        )
+    elif link and link.group(1) == "https://specs.apollo.dev/federation/v2.2":
+        definitions = load_schema_from_path(
+            os.path.join(dirname, "./definitions/fed2_2.graphql")
+        )
+    elif link and link.group(1) == "https://specs.apollo.dev/federation/v2.3":
+        definitions = load_schema_from_path(
+            os.path.join(dirname, "./definitions/fed2_3.graphql")
+        )
     else:
-        tdl.append(federation_one_service_type_defs)
+        definitions = load_schema_from_path(
+            os.path.join(dirname, "./definitions/fed1_0.graphql")
+        )
+
+    tdl.append(definitions)
 
     type_defs = join_type_defs(tdl)
     schema = make_executable_schema(
diff --git a/ariadne/contrib/federation/utils.py b/ariadne/contrib/federation/utils.py
index 98585db..9188053 100644
--- a/ariadne/contrib/federation/utils.py
+++ b/ariadne/contrib/federation/utils.py
@@ -57,6 +57,8 @@ _allowed_directives = [
     "tag",  # Federation 2 directive.
     "override",  # Federation 2 directive.
     "inaccessible",  # Federation 2 directive.
+    "composeDirective",  # Federation 2.1 directive.
+    "interfaceObject",  # Federation 2.3 directive.
 ]
 
 


Test patch:
diff --git a/tests/federation/test_schema.py b/tests/federation/test_schema.py
index f8f21aa..3a1f1da 100644
--- a/tests/federation/test_schema.py
+++ b/tests/federation/test_schema.py
@@ -21,7 +21,19 @@ from ariadne.contrib.federation import (
 def test_federation_one_schema_mark_type_tags():
     type_defs = """
         type Query
-        
+
+        directive @tag(name: String!) repeatable on
+            | FIELD_DEFINITION
+            | INTERFACE
+            | OBJECT
+            | UNION
+            | ARGUMENT_DEFINITION
+            | SCALAR
+            | ENUM
+            | ENUM_VALUE
+            | INPUT_OBJECT
+            | INPUT_FIELD_DEFINITION
+
         type Product @tag(name: "test") {
             upc: String!
             name: String
@@ -45,7 +57,19 @@ def test_federation_one_schema_mark_type_tags():
 def test_federation_one_schema_mark_type_repeated_tags():
     type_defs = """
         type Query
-        
+
+        directive @tag(name: String!) repeatable on
+            | FIELD_DEFINITION
+            | INTERFACE
+            | OBJECT
+            | UNION
+            | ARGUMENT_DEFINITION
+            | SCALAR
+            | ENUM
+            | ENUM_VALUE
+            | INPUT_OBJECT
+            | INPUT_FIELD_DEFINITION
+
         type Product @tag(name: "test") @tag(name: "test3") {
             upc: String!
             name: String
@@ -884,37 +908,3 @@ def test_federated_schema_without_query_is_valid():
 
     assert result.errors is None
     assert sic(result.data["_service"]["sdl"]) == sic(type_defs)
-
-
-def test_federation_2_0_version_is_detected_in_schema():
-    type_defs = """
-        extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@shareable", "@provides", "@external", "@tag", "@extends", "@override"]) 
-
-        type Product @key(fields: "upc") {
-            upc: String!
-            name: String
-            price: Int
-            weight: Int
-        }
-
-        type User @key(fields: "email") @extends {
-            email: ID! @external
-            name: String @override(from:"users")
-            totalProductsCreated: Int @external
-        } 
-    """
-
-    schema = make_federated_schema(type_defs)
-    result = graphql_sync(
-        schema,
-        """
-            query GetServiceDetails {
-                _service {
-                    sdl
-                }
-            }
-        """,
-    )
-
-    assert result.errors is None
-    assert sic(result.data["_service"]["sdl"]) == sic(type_defs)
diff --git a/tests/federation/test_schema_v2.py b/tests/federation/test_schema_v2.py
new file mode 100644
index 0000000..979b5bd
--- /dev/null
+++ b/tests/federation/test_schema_v2.py
@@ -0,0 +1,164 @@
+from graphql import graphql_sync
+from graphql.utilities import strip_ignored_characters as sic
+
+from ariadne.contrib.federation import (
+    make_federated_schema,
+)
+
+
+def test_federation_2_0_version_is_detected_in_schema():
+    type_defs = """
+        extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@shareable", "@provides", "@external", "@tag", "@extends", "@override"])
+
+        type Product @key(fields: "upc") {
+            upc: String!
+            name: String
+            price: Int
+            weight: Int
+        }
+
+        type User @key(fields: "email") @extends {
+            email: ID! @external
+            name: String @override(from:"users")
+            totalProductsCreated: Int @external
+        }
+    """
+
+    schema = make_federated_schema(type_defs)
+    result = graphql_sync(
+        schema,
+        """
+            query GetServiceDetails {
+                _service {
+                    sdl
+                }
+            }
+        """,
+    )
+
+    assert result.errors is None
+    assert sic(result.data["_service"]["sdl"]) == sic(type_defs)
+
+
+def test_federation_2_1_version_is_detected_in_schema():
+    type_defs = """
+        extend schema @link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@shareable", "@provides", "@external", "@tag", "@extends", "@override"])
+
+        type Product @key(fields: "upc") {
+            upc: String!
+            name: String
+            price: Int
+            weight: Int
+        }
+    """
+
+    schema = make_federated_schema(type_defs)
+    result = graphql_sync(
+        schema,
+        """
+            query GetServiceDetails {
+                _service {
+                    sdl
+                }
+            }
+        """,
+    )
+
+    assert result.errors is None
+    assert sic(result.data["_service"]["sdl"]) == sic(type_defs)
+
+
+def test_federation_2_2_version_is_detected_in_schema():
+    type_defs = """
+        extend schema @link(url: "https://specs.apollo.dev/federation/v2.2", import: ["@key", "@shareable", "@provides", "@external", "@tag", "@extends", "@override"])
+
+        type Product @key(fields: "upc") {
+            upc: String!
+            name: String
+            price: Int
+            weight: Int
+        }
+    """
+
+    schema = make_federated_schema(type_defs)
+    result = graphql_sync(
+        schema,
+        """
+            query GetServiceDetails {
+                _service {
+                    sdl
+                }
+            }
+        """,
+    )
+
+    assert result.errors is None
+    assert sic(result.data["_service"]["sdl"]) == sic(type_defs)
+
+
+def test_federated_schema_query_service_interface_object_federation_directive():
+    type_defs = """
+        extend schema
+            @link(
+                url: "https://specs.apollo.dev/federation/v2.3",
+                import: [
+                    "@key",
+                    "@shareable",
+                    "@provides",
+                    "@external",
+                    "@tag",
+                    "@extends",
+                    "@override",
+                    "@interfaceObject"
+                ]
+            )
+
+        type Query {
+            rootField: Review
+        }
+
+        type Review @interfaceObject @key(fields: "id") {
+            id: ID!
+        }
+    """
+
+    schema = make_federated_schema(type_defs)
+
+    result = graphql_sync(
+        schema,
+        """
+            query GetServiceDetails {
+                _service {
+                    sdl
+                }
+            }
+        """,
+    )
+
+    assert result.errors is None
+    assert sic(result.data["_service"]["sdl"]) == sic(
+        """
+            extend schema
+                @link(
+                    url: "https://specs.apollo.dev/federation/v2.3",
+                    import: [
+                        "@key",
+                        "@shareable",
+                        "@provides",
+                        "@external",
+                        "@tag",
+                        "@extends",
+                        "@override",
+                        "@interfaceObject"
+                    ]
+                )
+
+            type Query {
+                rootField: Review
+            }
+
+            type Review @interfaceObject @key(fields: "id") {
+                id: ID!
+            }
+        """
+    )