diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 304fc2f..3d3770d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,12 +3,12 @@ name: Unit Tests on: [push, pull_request] jobs: - build: + test: runs-on: ubuntu-latest strategy: max-parallel: 4 matrix: - python-version: ["3.9", "3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v1 @@ -21,10 +21,25 @@ jobs: python -m pip install --upgrade pip pip install -e ".[test]" - name: Run Unit Tests - run: py.test graphene_federation --cov=graphene_federation -vv - - name: Upload Coverage - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: py.test tests --cov=graphene_federation -vv + - name: Upload coverage run: | pip install coveralls coveralls --service=github + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_FLAG_NAME: ${{ matrix.python-version }} + COVERALLS_PARALLEL: true + + coveralls: + name: Indicate completion to coveralls.io + needs: test + runs-on: ubuntu-latest + container: python:3-slim + steps: + - name: Finished + run: | + pip3 install --upgrade coveralls + coveralls --service=github --finish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index 8c2dd0a..a2216dd 100644 --- a/Makefile +++ b/Makefile @@ -15,11 +15,11 @@ integration-tests: ## Run integration tests # ------------------------- dev-setup: ## Install development dependencies - docker-compose up -d && docker-compose exec graphene_federation bash + docker-compose up --build -d .PHONY: dev-setup tests: ## Run unit tests - docker-compose run graphene_federation py.test graphene_federation --cov=graphene_federation -vv + docker-compose run graphene_federation py.test tests --cov=graphene_federation -vv .PHONY: tests check-style: ## Run linting diff --git a/README.md b/README.md index eaf0a02..0bf6504 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Federation support for ![Graphene Logo](http://graphene-python.org/favicon.png) This repository is heavily based on the repo it was forked from... Huge thanks to [Preply for setting up the foundations](https://medium.com/preply-engineering/apollo-federation-support-in-graphene-761a0512456d). + WARNING: This version is not compatible with `graphene` version below v3. If you need to use a version compatible with `graphene` v2 I recommend using the version 1.0.0 of `graphene_federation`. @@ -26,16 +27,84 @@ If you need to use a version compatible with `graphene` v2 I recommend using the ## Supported Features -At the moment it supports: - * `sdl` (`_service` on field): enable to add schema in federation (as is) -* `@key` decorator (entity support): enable to perform queries across service boundaries (you can have more than one key per type) -* `@extend`: extend remote types -* `external()`: mark a field as external -* `requires()`: mark that field resolver requires other fields to be pre-fetched -* `provides()`/`@provides`: annotate the expected returned fieldset from a field on a base type that is guaranteed to be selectable by the gateway. -Each type which is decorated with `@key` or `@extend` is added to the `_Entity` union. +## Apollo Spec Supported + +- [x] v1.0 +- [x] v2.0 +- [x] v2.1 +- [x] v2.2 +- [x] v2.3 +- [x] v2.4 +- [x] v2.5 +- [x] v2.6 `STABLE_VERSION` . Rover dev supports only upto v2.6 +- [x] v2.7 `LATEST_VERSION` + +All directives could be easily integrated with the help of [graphene-directives](https://github.com/strollby/graphene-directives). +Now every directive's values are validated at run time itself by [graphene-directives](https://github.com/strollby/graphene-directives). + +### Directives (v2.7) + +```graphql +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @interfaceObject on OBJECT +directive @override(from: String!, label: String) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable repeatable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +directive @authenticated on + FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +directive @requiresScopes(scopes: [[federation__Scope!]!]!) on + FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +directive @policy(policies: [[federation__Policy!]!]!) on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +scalar federation__Policy +scalar federation__Scope +scalar FieldSet +``` + +Read about directives in [official documentation](https://www.apollographql.com/docs/federation/federated-types/federated-directives) + + +Each type which is decorated with `@key` or `@extends` is added to the `_Entity` union. The [`__resolve_reference` method](https://www.apollographql.com/docs/federation/api/apollo-federation/#__resolvereference) can be defined for each type that is an entity. Note that since the notation with double underscores can be problematic in Python for model inheritance this resolver method can also be named `_resolve_reference` (the `__resolve_reference` method will take precedence if both are declared). @@ -57,8 +126,10 @@ It implements a federation schema for a basic e-commerce application over three First add an account service that expose a `User` type that can then be referenced in other services by its `id` field: ```python -from graphene import Field, ID, ObjectType, String -from graphene_federation import build_schema, key +from graphene import Field, Int, ObjectType, String + +from graphene_federation import LATEST_VERSION, build_schema, key + @key("id") class User(ObjectType): @@ -71,18 +142,22 @@ class User(ObjectType): """ return User(id=self.id, email=f"user_{self.id}@mail.com") + class Query(ObjectType): me = Field(User) -schema = build_schema(query=Query) + +schema = build_schema(query=Query, federation_version=LATEST_VERSION) ``` ### Product The product service exposes a `Product` type that can be used by other services via the `upc` field: ```python -from graphene import Argument, ID, Int, List, ObjectType, String -from graphene_federation import build_schema, key +from graphene import Argument, Int, List, ObjectType, String + +from graphene_federation import LATEST_VERSION, build_schema, key + @key("upc") class Product(ObjectType): @@ -96,10 +171,12 @@ class Product(ObjectType): """ return Product(upc=self.upc, name=f"product {self.upc}") + class Query(ObjectType): topProducts = List(Product, first=Argument(Int, default_value=5)) -schema = build_schema(query=Query) + +schema = build_schema(query=Query, federation_version=LATEST_VERSION) ``` ### Reviews @@ -108,10 +185,12 @@ It also has the ability to provide the username of the `User`. On top of that it adds to the `User`/`Product` types (that are both defined in other services) the ability to get their reviews. ```python -from graphene import Field, ID, Int, List, ObjectType, String -from graphene_federation import build_schema, extend, external, provides +from graphene import Field, Int, List, ObjectType, String + +from graphene_federation import LATEST_VERSION, build_schema, external, key, provides -@extend("id") + +@key("id") class User(ObjectType): id = external(Int(required=True)) reviews = List(lambda: Review) @@ -122,22 +201,24 @@ class User(ObjectType): """ return [] -@extend("upc") + +@key("upc") class Product(ObjectType): upc = external(String(required=True)) reviews = List(lambda: Review) -# Note that both the base type and the field need to be decorated with `provides` (on the field itself you need to specify which fields get provided). -@provides + class Review(ObjectType): body = String() author = provides(Field(User), fields="username") product = Field(Product) + class Query(ObjectType): review = Field(Review) -schema = build_schema(query=Query) + +schema = build_schema(query=Query, federation_version=LATEST_VERSION) ``` ### Federation @@ -167,13 +248,192 @@ You can find more examples in the unit / integration tests and [examples folder] There is also a cool [example](https://github.com/preply/graphene-federation/issues/1) of integration with Mongoengine. ------------------------ +## Other Notes + +### build_schema new arguments + +- `schema_directives` (`Collection[SchemaDirective]`): Directives that can be defined at `DIRECTIVE_LOCATION.SCHEMA` with their argument values. +- `include_graphql_spec_directives` (`bool`): Includes directives defined by GraphQL spec (`@include`, `@skip`, `@deprecated`, `@specifiedBy`) +- `federation_version` (`FederationVersion`): Specify the version explicit (default STABLE_VERSION) + +### Directives Additional arguments + +- `federation_version`: (`FederationVersion` = `LATEST_VERSION`) : You can use this to take a directive from a particular federation version + +Note: The `federation_version` in `build_schema` is given higher priority. If the directive you have chosen is not compatible, it will raise an error + +### Custom Directives + +You can define custom directives as follows + +```python +from graphene import Field, ObjectType, String +from graphql import GraphQLArgument, GraphQLInt, GraphQLNonNull + +from graphene_federation import ComposableDirective, DirectiveLocation, LATEST_VERSION +from graphene_federation import build_schema + +CacheDirective = ComposableDirective( + name="cache", + locations=[DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT], + args={ + "maxAge": GraphQLArgument( + GraphQLNonNull(GraphQLInt), description="Specifies the maximum age for cache in seconds." + ), + }, + description="Caching directive to control cache behavior.", + spec_url="https://specs.example.dev/directives/v1.0", +) + +cache = CacheDirective.decorator() + + +@cache(max_age=20) +class Review(ObjectType): + body = cache(field=String(), max_age=100) + + +class Query(ObjectType): + review = Field(Review) + + +schema = build_schema( + query=Query, + directives=(CacheDirective,), + federation_version=LATEST_VERSION , +) +``` + +This will automatically add @link and @composeDirective to schema + + +```graphql +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@composeDirective"]) + @link(url: "https://specs.example.dev/directives/v1.0", import: ["@cache"]) + @composeDirective(name: "@cache") + +"""Caching directive to control cache behavior.""" +directive @cache( + """Specifies the maximum age for cache in seconds.""" + maxAge: Int! +) on FIELD_DEFINITION | OBJECT -## Known issues +type Query { + review: Review + _service: _Service! +} + +type Review @cache(maxAge: 20) { + body: String @cache(maxAge: 100) +} +``` + +If you wish to add the schema_directives `@link` `@composeDirective` manually. +You can pass the `add_to_schema_directives` as `False` + +```python +from graphene import Field, ObjectType, String +from graphql import GraphQLArgument, GraphQLInt, GraphQLNonNull + +from graphene_federation import (ComposableDirective, DirectiveLocation, LATEST_VERSION, build_schema, + compose_directive, link_directive) + +CacheDirective = ComposableDirective( + name="cache", + locations=[DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT], + args={ + "maxAge": GraphQLArgument( + GraphQLNonNull(GraphQLInt), description="Specifies the maximum age for cache in seconds." + ), + }, + description="Caching directive to control cache behavior.", + add_to_schema_directives=False +) + +cache = CacheDirective.decorator() + + +@cache(max_age=20) +class Review(ObjectType): + body = cache(field=String(), max_age=100) + + +class Query(ObjectType): + review = Field(Review) + + +schema = build_schema( + query=Query, + directives=(CacheDirective,), + schema_directives=( + link_directive(url="https://specs.example.dev/directives/v1.0", import_=['@cache']), + compose_directive(name='@cache'), + ), + federation_version=LATEST_VERSION, +) +``` + +### Custom field name + +When using decorator on a field with custom name + +#### Case 1 (auto_camelcase=False) + +```python +@key("identifier") +@key("validEmail") +class User(ObjectType): + identifier = ID() + email = String(name="validEmail") + +class Query(ObjectType): + user = Field(User) + +schema = build_schema(query=Query, federation_version=LATEST_VERSION, auto_camelcase=False) # Disable auto_camelcase +``` + +This works correctly. +By default `fields` of `@key`,`@requires` and `@provides` are not converted to camel case if `auto_camelcase` is set to `False` + +#### Case 2 (auto_camelcase=True) +```python +@key("identifier") +@key("valid_email") +class User(ObjectType): + identifier = ID() + email = String(name="valid_email") + +class Query(ObjectType): + user = Field(User) -1. decorators will not work properly on fields with custom names for example `some_field = String(name='another_name')` +schema = build_schema(query=Query, federation_version=LATEST_VERSION) # auto_camelcase Enabled +``` + +This will raise an error `@key, field "validEmail" does not exist on type "User"`. +Because The decorator auto camel-cased the `field` value of key, as schema has `auto_camelcase=True` (default) + +To fix this, pass `auto_case=False` in the `@key`, `@requires` or `@provides` argument + +```python +@key("identifier") +@key("valid_email", auto_case=False) +class User(ObjectType): + identifier = ID() + email = String(name="valid_email") + +class Query(ObjectType): + user = Field(User) + +schema = build_schema(query=Query, federation_version=LATEST_VERSION) # auto_camelcase=True +``` ------------------------ +## Known Issues + +- Using `@composeDirective` with `@link` in Federation `v2.6` shows error in rover, rover cli only supports upto `v2.5` as of 16/01/2024 + ## Contributing * You can run the unit tests by doing: `make tests`. diff --git a/examples/extend.py b/examples/extend.py index cdb3e1c..92562ae 100644 --- a/examples/extend.py +++ b/examples/extend.py @@ -1,8 +1,10 @@ import graphene -from graphene_federation import build_schema, extend, external +from graphene_federation import build_schema, extends, external, key -@extend(fields="id") + +@key("id") +@extends class Message(graphene.ObjectType): id = external(graphene.Int(required=True)) @@ -28,4 +30,4 @@ def resolve_file(self, **kwargs): """ result = schema.execute(query) print(result.data) -# {'sdl': 'type Query {\n message: Message\n}\n\nextend type Message @key(fields: "id") {\n id: Int! @external\n}'}} +# {'sdl': 'type Query {\n message: Message\n}\n\n type Message @key(fields: "id") @extends {\n id: Int! @external\n}'}} diff --git a/examples/inaccessible.py b/examples/inaccessible.py index 1c9c715..aac4f16 100644 --- a/examples/inaccessible.py +++ b/examples/inaccessible.py @@ -1,12 +1,12 @@ import graphene from graphene_federation import ( + LATEST_VERSION, inaccessible, external, provides, key, override, - shareable, ) from graphene_federation import build_schema @@ -64,7 +64,9 @@ class Query(graphene.ObjectType): schema = build_schema( - Query, enable_federation_2=True, types=(ReviewInterface, SearchResult, Review) + Query, + federation_version=LATEST_VERSION, + types=(ReviewInterface, SearchResult, Review), ) query = """ diff --git a/examples/override.py b/examples/override.py index 6e09a07..0c9bf67 100644 --- a/examples/override.py +++ b/examples/override.py @@ -1,12 +1,11 @@ import graphene from graphene_federation import ( + LATEST_VERSION, build_schema, - shareable, - external, + inaccessible, key, override, - inaccessible, ) @@ -21,7 +20,7 @@ class Query(graphene.ObjectType): position = graphene.Field(Product) -schema = build_schema(Query, enable_federation_2=True) +schema = build_schema(Query, federation_version=LATEST_VERSION) query = """ query getSDL { diff --git a/examples/shareable.py b/examples/shareable.py index 5c983cf..a9cef2f 100644 --- a/examples/shareable.py +++ b/examples/shareable.py @@ -3,7 +3,7 @@ from graphene_federation.shareable import shareable -from graphene_federation import build_schema +from graphene_federation import LATEST_VERSION, build_schema @shareable @@ -40,7 +40,7 @@ class Query(graphene.ObjectType): position = graphene.Field(Position) -schema = build_schema(Query, enable_federation_2=True, types=(SearchResult,)) +schema = build_schema(Query, federation_version=LATEST_VERSION, types=(SearchResult,)) query = """ query getSDL { diff --git a/examples/tag.py b/examples/tag.py index 6f03da7..c895a59 100644 --- a/examples/tag.py +++ b/examples/tag.py @@ -1,8 +1,8 @@ import graphene - -from graphene_federation import build_schema, key, inaccessible, shareable from graphene_federation.tag import tag +from graphene_federation import LATEST_VERSION, build_schema, inaccessible, shareable + class Product(graphene.ObjectType): id = graphene.ID(required=True) @@ -15,7 +15,7 @@ class Query(graphene.ObjectType): position = graphene.Field(Product) -schema = build_schema(Query, enable_federation_2=True) +schema = build_schema(Query, federation_version=LATEST_VERSION) query = """ query getSDL { diff --git a/federation_spec/federation-v1.0.graphql b/federation_spec/federation-v1.0.graphql new file mode 100644 index 0000000..19d492f --- /dev/null +++ b/federation_spec/federation-v1.0.graphql @@ -0,0 +1,9 @@ +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 +scalar _FieldSet + +# this is an optional directive +# used in frameworks that don't natively support GraphQL extend syntax +directive @extends on OBJECT | INTERFACE diff --git a/federation_spec/federation-v2.0.graphql b/federation_spec/federation-v2.0.graphql new file mode 100644 index 0000000..494e97a --- /dev/null +++ b/federation_spec/federation-v2.0.graphql @@ -0,0 +1,30 @@ +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 \ No newline at end of file diff --git a/federation_spec/federation-v2.1.graphql b/federation_spec/federation-v2.1.graphql new file mode 100644 index 0000000..d07dfaf --- /dev/null +++ b/federation_spec/federation-v2.1.graphql @@ -0,0 +1,31 @@ +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @override(from: String!) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +scalar FieldSet diff --git a/federation_spec/federation-v2.2.graphql b/federation_spec/federation-v2.2.graphql new file mode 100644 index 0000000..b75272b --- /dev/null +++ b/federation_spec/federation-v2.2.graphql @@ -0,0 +1,31 @@ +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @override(from: String!) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable repeatable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +scalar FieldSet diff --git a/federation_spec/federation-v2.3.graphql b/federation_spec/federation-v2.3.graphql new file mode 100644 index 0000000..8bf0015 --- /dev/null +++ b/federation_spec/federation-v2.3.graphql @@ -0,0 +1,32 @@ +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @interfaceObject on OBJECT +directive @override(from: String!) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable repeatable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +scalar FieldSet diff --git a/federation_spec/federation-v2.4.graphql b/federation_spec/federation-v2.4.graphql new file mode 100644 index 0000000..8bf0015 --- /dev/null +++ b/federation_spec/federation-v2.4.graphql @@ -0,0 +1,32 @@ +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @interfaceObject on OBJECT +directive @override(from: String!) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable repeatable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +scalar FieldSet diff --git a/federation_spec/federation-v2.5.graphql b/federation_spec/federation-v2.5.graphql new file mode 100644 index 0000000..815ff2c --- /dev/null +++ b/federation_spec/federation-v2.5.graphql @@ -0,0 +1,45 @@ +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @interfaceObject on OBJECT +directive @override(from: String!) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable repeatable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +directive @authenticated on + FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +directive @requiresScopes(scopes: [[federation__Scope!]!]!) on + FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +scalar federation__Scope +scalar FieldSet diff --git a/federation_spec/federation-v2.6.graphql b/federation_spec/federation-v2.6.graphql new file mode 100644 index 0000000..ebff3a8 --- /dev/null +++ b/federation_spec/federation-v2.6.graphql @@ -0,0 +1,52 @@ +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @interfaceObject on OBJECT +directive @override(from: String!) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable repeatable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +directive @authenticated on + FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +directive @requiresScopes(scopes: [[federation__Scope!]!]!) on + FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +directive @policy(policies: [[federation__Policy!]!]!) on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +scalar federation__Policy +scalar federation__Scope +scalar FieldSet diff --git a/federation_spec/federation-v2.7.graphql b/federation_spec/federation-v2.7.graphql new file mode 100644 index 0000000..39e27f6 --- /dev/null +++ b/federation_spec/federation-v2.7.graphql @@ -0,0 +1,52 @@ +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @interfaceObject on OBJECT +directive @override(from: String!, label: String) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable repeatable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +directive @authenticated on + FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +directive @requiresScopes(scopes: [[federation__Scope!]!]!) on + FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +directive @policy(policies: [[federation__Policy!]!]!) on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | SCALAR + | ENUM +scalar federation__Policy +scalar federation__Scope +scalar FieldSet diff --git a/graphene_federation/__init__.py b/graphene_federation/__init__.py index 73786d0..5ba433d 100644 --- a/graphene_federation/__init__.py +++ b/graphene_federation/__init__.py @@ -1,10 +1,47 @@ -from .main import build_schema -from .entity import key -from .extend import extend -from .external import external -from .requires import requires -from .shareable import shareable -from .inaccessible import inaccessible -from .provides import provides -from .override import override -from .compose_directive import mark_composable, is_composable +from graphene_directives import DirectiveLocation + +from .apollo_versions import FederationVersion, LATEST_VERSION, STABLE_VERSION +from .composable_directive import ComposableDirective +from .directives import ( + authenticated, + extends, + external, + inaccessible, + interface_object, + key, + override, + policy, + provides, + requires, + requires_scope, + shareable, + tag, +) +from .schema import build_schema +from .schema_directives import compose_directive, link_directive +from .service import get_sdl + +__all__ = [ + "FederationVersion", + "LATEST_VERSION", + "STABLE_VERSION", + "build_schema", + "ComposableDirective", + "DirectiveLocation", + "authenticated", + "extends", + "external", + "inaccessible", + "interface_object", + "key", + "override", + "provides", + "policy", + "requires", + "requires_scope", + "shareable", + "tag", + "compose_directive", + "link_directive", + "get_sdl", +] diff --git a/graphene_federation/apollo_versions/__init__.py b/graphene_federation/apollo_versions/__init__.py new file mode 100644 index 0000000..46e398f --- /dev/null +++ b/graphene_federation/apollo_versions/__init__.py @@ -0,0 +1,63 @@ +from graphql import GraphQLDirective + +from .v1_0 import get_directives as get_directives_v1_0 +from .v2_0 import get_directives as get_directives_v2_0 +from .v2_1 import get_directives as get_directives_v2_1 +from .v2_2 import get_directives as get_directives_v2_2 +from .v2_3 import get_directives as get_directives_v2_3 +from .v2_4 import get_directives as get_directives_v2_4 +from .v2_5 import get_directives as get_directives_v2_5 +from .v2_6 import get_directives as get_directives_v2_6 +from .v2_7 import get_directives as get_directives_v2_7 +from .version import FederationVersion + +LATEST_VERSION = FederationVersion.VERSION_2_7 + +# Stable version is determined with the latest version that rover cli supports +STABLE_VERSION = FederationVersion.VERSION_2_6 + + +def get_directives_based_on_version( + federation_version: FederationVersion, +) -> dict[str, GraphQLDirective]: + """ + Returns a dictionary of [directive_name, directive] for the specified federation version + + If no match is found for the specified federation version, the latest is taken + """ + if federation_version == FederationVersion.VERSION_1_0: + return get_directives_v1_0() + if federation_version == FederationVersion.VERSION_2_0: + return get_directives_v2_0() + if federation_version == FederationVersion.VERSION_2_1: + return get_directives_v2_1() + if federation_version == FederationVersion.VERSION_2_2: + return get_directives_v2_2() + if federation_version == FederationVersion.VERSION_2_3: + return get_directives_v2_3() + if federation_version == FederationVersion.VERSION_2_4: + return get_directives_v2_4() + if federation_version == FederationVersion.VERSION_2_5: + return get_directives_v2_5() + if federation_version == FederationVersion.VERSION_2_6: + return get_directives_v2_6() + if federation_version == FederationVersion.VERSION_2_7: + return get_directives_v2_7() + + return get_directives_v2_7() + + +def get_directive_from_name( + directive_name: str, federation_version: FederationVersion +) -> GraphQLDirective: + """ + Get the GraphQL directive for the specified name with the given federation version + """ + directive = get_directives_based_on_version(federation_version).get( + directive_name, None + ) + if directive is None: + raise ValueError( + f"@{directive_name} not supported on federation version {federation_version}" + ) + return directive diff --git a/graphene_federation/apollo_versions/v1_0.py b/graphene_federation/apollo_versions/v1_0.py new file mode 100644 index 0000000..672924e --- /dev/null +++ b/graphene_federation/apollo_versions/v1_0.py @@ -0,0 +1,81 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import GraphQLArgument, GraphQLDirective, GraphQLNonNull + +from graphene_federation.scalars import _FieldSet +from graphene_federation.transform import field_set_case_transform +from graphene_federation.validators import ( + validate_key, + validate_provides, + validate_requires, +) + +key_directive = CustomDirective( + name="key", + locations=[ + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + ], + args={"fields": GraphQLArgument(GraphQLNonNull(_FieldSet))}, + description="Federation @key directive", + is_repeatable=True, + add_definition_to_schema=False, + non_field_validator=validate_key, + input_transform=field_set_case_transform, +) + +requires_directive = CustomDirective( + name="requires", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={"fields": GraphQLArgument(GraphQLNonNull(_FieldSet))}, + description="Federation @requires directive", + add_definition_to_schema=False, + field_validator=validate_requires, + input_transform=field_set_case_transform, +) + + +provides_directive = CustomDirective( + name="provides", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={"fields": GraphQLArgument(GraphQLNonNull(_FieldSet))}, + description="Federation @provides directive", + add_definition_to_schema=False, + field_validator=validate_provides, + input_transform=field_set_case_transform, +) + +external_directive = CustomDirective( + name="external", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + description="Federation @external directive", + add_definition_to_schema=False, +) + +extends_directive = CustomDirective( + name="extends", + locations=[ + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + ], + description="Federation @extends directive", + add_definition_to_schema=False, +) + + +def get_directives() -> dict[str, GraphQLDirective]: + return { + directive.name: directive + for directive in [ + key_directive, + requires_directive, + provides_directive, + external_directive, + extends_directive, + ] + } diff --git a/graphene_federation/apollo_versions/v2_0.py b/graphene_federation/apollo_versions/v2_0.py new file mode 100644 index 0000000..827d953 --- /dev/null +++ b/graphene_federation/apollo_versions/v2_0.py @@ -0,0 +1,152 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import ( + GraphQLArgument, + GraphQLBoolean, + GraphQLDirective, + GraphQLNonNull, + GraphQLString, +) + +from graphene_federation.scalars import FieldSet +from graphene_federation.transform import field_set_case_transform +from graphene_federation.validators import ( + validate_key, + validate_provides, + validate_requires, +) +from .v1_0 import extends_directive + +key_directive = CustomDirective( + name="key", + locations=[ + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + ], + args={ + "fields": GraphQLArgument(GraphQLNonNull(FieldSet)), + # Changed from v1.0 + "resolvable": GraphQLArgument(GraphQLBoolean, default_value=True), + }, + description="Federation @key directive", + is_repeatable=True, + add_definition_to_schema=False, + non_field_validator=validate_key, + input_transform=field_set_case_transform, +) + +requires_directive = CustomDirective( + name="requires", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={ + "fields": GraphQLArgument(GraphQLNonNull(FieldSet)) + }, # Changed _FieldSet -> FieldSet + description="Federation @requires directive", + add_definition_to_schema=False, + field_validator=validate_requires, + input_transform=field_set_case_transform, +) + + +provides_directive = CustomDirective( + name="provides", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={ + "fields": GraphQLArgument(GraphQLNonNull(FieldSet)) + }, # Changed _FieldSet -> FieldSet + description="Federation @provides directive", + add_definition_to_schema=False, + field_validator=validate_provides, + input_transform=field_set_case_transform, +) + + +external_directive = CustomDirective( + name="external", + locations=[ + DirectiveLocation.OBJECT, # Changed from v1.0 + DirectiveLocation.FIELD_DEFINITION, + ], + description="Federation @external directive", + add_definition_to_schema=False, +) + + +shareable_directive = CustomDirective( + name="shareable", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + ], + description="Federation @shareable directive", + add_definition_to_schema=False, +) + + +override_directive = CustomDirective( + name="override", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={ + "from": GraphQLArgument(GraphQLNonNull(GraphQLString)), + }, + description="Federation @override directive", + add_definition_to_schema=False, +) + +inaccessible_directive = CustomDirective( + name="inaccessible", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + DirectiveLocation.UNION, + DirectiveLocation.ENUM, + DirectiveLocation.ENUM_VALUE, + DirectiveLocation.SCALAR, + DirectiveLocation.INPUT_OBJECT, + DirectiveLocation.INPUT_FIELD_DEFINITION, + DirectiveLocation.ARGUMENT_DEFINITION, + ], + description="Federation @inaccessible directive", + add_definition_to_schema=False, +) + +tag_directive = CustomDirective( + name="tag", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.INTERFACE, + DirectiveLocation.OBJECT, + DirectiveLocation.UNION, + DirectiveLocation.ARGUMENT_DEFINITION, + DirectiveLocation.SCALAR, + DirectiveLocation.ENUM, + DirectiveLocation.ENUM_VALUE, + DirectiveLocation.INPUT_OBJECT, + DirectiveLocation.INPUT_FIELD_DEFINITION, + ], + description="Federation @tag directive", + add_definition_to_schema=False, +) + + +def get_directives() -> dict[str, GraphQLDirective]: + return { + directive.name: directive + for directive in [ + key_directive, + requires_directive, + provides_directive, + external_directive, + shareable_directive, + extends_directive, # From v1.0 + override_directive, + inaccessible_directive, + tag_directive, + ] + } diff --git a/graphene_federation/apollo_versions/v2_1.py b/graphene_federation/apollo_versions/v2_1.py new file mode 100644 index 0000000..18bc3f5 --- /dev/null +++ b/graphene_federation/apollo_versions/v2_1.py @@ -0,0 +1,27 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import ( + GraphQLArgument, + GraphQLDirective, + GraphQLNonNull, + GraphQLString, +) + +from .v2_0 import get_directives as get_directives_v2_0 + +compose_directive = CustomDirective( + name="composeDirective", + locations=[ + DirectiveLocation.SCHEMA, + ], + args={ + "name": GraphQLArgument(GraphQLNonNull(GraphQLString)), + }, + description="Federation @composeDirective directive", + add_definition_to_schema=False, +) + + +def get_directives() -> dict[str, GraphQLDirective]: + directives = get_directives_v2_0() + directives.update({directive.name: directive for directive in [compose_directive]}) + return directives diff --git a/graphene_federation/apollo_versions/v2_2.py b/graphene_federation/apollo_versions/v2_2.py new file mode 100644 index 0000000..7842917 --- /dev/null +++ b/graphene_federation/apollo_versions/v2_2.py @@ -0,0 +1,23 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import GraphQLDirective + +from .v2_1 import get_directives as get_directives_v2_1 + +shareable_directive = CustomDirective( + name="shareable", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + ], + description="Federation @shareable directive", + add_definition_to_schema=False, + is_repeatable=True, # Changed from v2.1 +) + + +def get_directives() -> dict[str, GraphQLDirective]: + directives = get_directives_v2_1() + directives.update( + {directive.name: directive for directive in [shareable_directive]} + ) + return directives diff --git a/graphene_federation/apollo_versions/v2_3.py b/graphene_federation/apollo_versions/v2_3.py new file mode 100644 index 0000000..e179c9a --- /dev/null +++ b/graphene_federation/apollo_versions/v2_3.py @@ -0,0 +1,22 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import GraphQLDirective + +from .v2_2 import get_directives as get_directives_v2_2 + +interface_object_directive = CustomDirective( + name="interfaceObject", + locations=[ + DirectiveLocation.OBJECT, + ], + description="Federation @interfaceObject directive", + add_definition_to_schema=False, + is_repeatable=True, +) + + +def get_directives() -> dict[str, GraphQLDirective]: + directives = get_directives_v2_2() + directives.update( + {directive.name: directive for directive in [interface_object_directive]} + ) + return directives diff --git a/graphene_federation/apollo_versions/v2_4.py b/graphene_federation/apollo_versions/v2_4.py new file mode 100644 index 0000000..194f22c --- /dev/null +++ b/graphene_federation/apollo_versions/v2_4.py @@ -0,0 +1,8 @@ +from graphql import GraphQLDirective + +from .v2_3 import get_directives as get_directives_v2_3 + + +# No Change, Added Subscription Support +def get_directives() -> dict[str, GraphQLDirective]: + return get_directives_v2_3() diff --git a/graphene_federation/apollo_versions/v2_5.py b/graphene_federation/apollo_versions/v2_5.py new file mode 100644 index 0000000..190e7de --- /dev/null +++ b/graphene_federation/apollo_versions/v2_5.py @@ -0,0 +1,52 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import GraphQLArgument, GraphQLDirective, GraphQLList, GraphQLNonNull + +from .v2_4 import get_directives as get_directives_v2_4 +from graphene_federation.scalars import FederationScope + +authenticated_directive = CustomDirective( + name="authenticated", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + DirectiveLocation.SCALAR, + DirectiveLocation.ENUM, + ], + description="Federation @authenticated directive", + add_definition_to_schema=False, +) + +requires_scope_directive = CustomDirective( + name="requiresScopes", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + DirectiveLocation.SCALAR, + DirectiveLocation.ENUM, + ], + args={ + "scopes": GraphQLArgument( + GraphQLNonNull( + GraphQLList( + GraphQLNonNull(GraphQLList(GraphQLNonNull(FederationScope))) + ) + ) + ), + }, + description="Federation @requiresScopes directive", + add_definition_to_schema=False, +) + + +# No Change, Added Subscription Support +def get_directives() -> dict[str, GraphQLDirective]: + directives = get_directives_v2_4() + directives.update( + { + directive.name: directive + for directive in [authenticated_directive, requires_scope_directive] + } + ) + return directives diff --git a/graphene_federation/apollo_versions/v2_6.py b/graphene_federation/apollo_versions/v2_6.py new file mode 100644 index 0000000..bf62d36 --- /dev/null +++ b/graphene_federation/apollo_versions/v2_6.py @@ -0,0 +1,34 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import GraphQLArgument, GraphQLDirective, GraphQLList, GraphQLNonNull + +from .v2_5 import get_directives as get_directives_v2_5 +from graphene_federation.scalars import FederationPolicy + +policy_directive = CustomDirective( + name="policy", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + DirectiveLocation.SCALAR, + DirectiveLocation.ENUM, + ], + args={ + "policies": GraphQLArgument( + GraphQLNonNull( + GraphQLList( + GraphQLNonNull(GraphQLList(GraphQLNonNull(FederationPolicy))) + ) + ) + ), + }, + description="Federation @policy directive", + add_definition_to_schema=False, +) + + +# No Change, Added Subscription Support +def get_directives() -> dict[str, GraphQLDirective]: + directives = get_directives_v2_5() + directives.update({directive.name: directive for directive in [policy_directive]}) + return directives diff --git a/graphene_federation/apollo_versions/v2_7.py b/graphene_federation/apollo_versions/v2_7.py new file mode 100644 index 0000000..6714bab --- /dev/null +++ b/graphene_federation/apollo_versions/v2_7.py @@ -0,0 +1,24 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import GraphQLArgument, GraphQLDirective, GraphQLNonNull, GraphQLString + +from .v2_6 import get_directives as get_directives_v2_6 + +override_directive = CustomDirective( + name="override", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={ + "from": GraphQLArgument(GraphQLNonNull(GraphQLString)), + "label": GraphQLArgument(GraphQLString), + }, + description="Federation @override directive", + add_definition_to_schema=False, +) + + +# @override Change, Added label argument +def get_directives() -> dict[str, GraphQLDirective]: + directives = get_directives_v2_6() + directives.update({directive.name: directive for directive in [override_directive]}) + return directives diff --git a/graphene_federation/apollo_versions/version.py b/graphene_federation/apollo_versions/version.py new file mode 100644 index 0000000..fc01edb --- /dev/null +++ b/graphene_federation/apollo_versions/version.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class FederationVersion(Enum): + VERSION_1_0 = "1.0" + VERSION_2_0 = "2.0" + VERSION_2_1 = "2.1" + VERSION_2_2 = "2.2" + VERSION_2_3 = "2.3" + VERSION_2_4 = "2.4" + VERSION_2_5 = "2.5" + VERSION_2_6 = "2.6" + VERSION_2_7 = "2.7" diff --git a/graphene_federation/composable_directive.py b/graphene_federation/composable_directive.py new file mode 100644 index 0000000..1055a1c --- /dev/null +++ b/graphene_federation/composable_directive.py @@ -0,0 +1,73 @@ +from typing import Any, Callable, Collection, Dict, Optional + +from graphene_directives import CustomDirective, DirectiveLocation, directive_decorator +from graphql import ( + DirectiveDefinitionNode, + GraphQLArgument, + GraphQLDirective, +) + + +class ComposableDirective(GraphQLDirective): + def __init__( + self, + name: str, + locations: Collection[DirectiveLocation], + args: Optional[Dict[str, GraphQLArgument]] = None, + is_repeatable: bool = False, + description: Optional[str] = None, + extensions: Optional[Dict[str, Any]] = None, + ast_node: Optional[DirectiveDefinitionNode] = None, + spec_url: str = None, + add_to_schema_directives: bool = True, + ) -> None: + """ + Creates a Federation Supported GraphQLDirective + + :param name: (GraphQLDirective param) + :param args: (GraphQLDirective param) + :param is_repeatable: (GraphQLDirective param) + :param description: (GraphQLDirective param) + :param extensions: (GraphQLDirective param) + :param ast_node: (GraphQLDirective param) + + :param spec_url: url of the directive to be set in url of @link + :param add_to_schema_directives: Adds schema_directives @composeDirective and @link to schema automatically + """ + if add_to_schema_directives: + assert spec_url is not None, "ComposableDirective requires spec_url" + + self.spec_url = spec_url + self.add_to_schema_directives = add_to_schema_directives + + self.graphene_directive = CustomDirective( + name=name, + locations=locations, + args=args, + is_repeatable=is_repeatable, + description=description, + extensions=extensions, + ast_node=ast_node, + ) + parent_attributes = { + "name", + "locations", + "args", + "is_repeatable", + "description", + "extensions", + "ast_node", + } + parent_kwargs = {} + + # Copy attributes of graphene_directive + for k, v in self.graphene_directive.__dict__.items(): + if k not in parent_attributes: + setattr(self, k, v) + else: + parent_kwargs[k] = v + + super().__init__(**parent_kwargs) + + def decorator(self) -> Callable: + return directive_decorator(self.graphene_directive) diff --git a/graphene_federation/compose_directive.py b/graphene_federation/compose_directive.py deleted file mode 100644 index 824d697..0000000 --- a/graphene_federation/compose_directive.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Optional - -from graphql import GraphQLDirective - - -def is_composable(directive: GraphQLDirective) -> bool: - """ - Checks if the directive will be composed to supergraph. - Validates the presence of _compose_import_url attribute - """ - return hasattr(directive, "_compose_import_url") - - -def mark_composable( - directive: GraphQLDirective, import_url: str, import_as: Optional[str] = None -) -> GraphQLDirective: - """ - Marks directive with _compose_import_url and _compose_import_as - Enables Identification of directives which are to be composed to supergraph - """ - setattr(directive, "_compose_import_url", import_url) - if import_as: - setattr(directive, "_compose_import_as", import_as) - return directive - - -def compose_directive_schema_extensions(directives: list[GraphQLDirective]): - """ - Generates schema extends string for ComposeDirective - """ - link_schema = "" - compose_directive_schema = "" - # Using dictionary to generate cleaner schema when multiple directives imports from same URL. - links: dict = {} - - for directive in directives: - # TODO: Replace with walrus operator when dropping Python 3.8 support - if hasattr(directive, "_compose_import_url"): - compose_import_url = getattr(directive, "_compose_import_url") - if hasattr(directive, "_compose_import_as"): - compose_import_as = getattr(directive, "_compose_import_as") - import_value = ( - f'{{ name: "@{directive.name}, as: "@{compose_import_as}" }}' - ) - imported_name = compose_import_as - else: - import_value = f'"@{directive.name}"' - imported_name = directive.name - - import_url = compose_import_url - - if links.get(import_url): - links[import_url] = links[import_url].append(import_value) - else: - links[import_url] = [import_value] - - compose_directive_schema += ( - f' @composeDirective(name: "@{imported_name}")\n' - ) - - for import_url in links: - link_schema += f' @link(url: "{import_url}", import: [{",".join(value for value in links[import_url])}])\n' - - return link_schema + compose_directive_schema diff --git a/graphene_federation/directives/__init__.py b/graphene_federation/directives/__init__.py new file mode 100644 index 0000000..c07e952 --- /dev/null +++ b/graphene_federation/directives/__init__.py @@ -0,0 +1,13 @@ +from .authenticated import authenticated +from .extends import extends +from .external import external +from .inaccessible import inaccessible +from .interface_object import interface_object +from .key import key +from .override import override +from .policy import policy +from .provides import provides +from .requires import requires +from .requires_scopes import requires_scope +from .shareable import shareable +from .tag import tag diff --git a/graphene_federation/directives/authenticated.py b/graphene_federation/directives/authenticated.py new file mode 100644 index 0000000..32ffbb9 --- /dev/null +++ b/graphene_federation/directives/authenticated.py @@ -0,0 +1,35 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from .utils import is_non_field + + +def authenticated( + graphene_type=None, + *, + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Indicates to composition that the target element is accessible only to the authenticated supergraph users. + For more granular access control, see the @requiresScopes directive. + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#authenticated + """ + directive = get_directive_from_name("authenticated", federation_version) + decorator = directive_decorator(directive) + + def wrapper(field_or_type): + if is_non_field(field_or_type): + return decorator(field=None)(field_or_type) + return decorator(field=field_or_type) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/extends.py b/graphene_federation/directives/extends.py new file mode 100644 index 0000000..f827711 --- /dev/null +++ b/graphene_federation/directives/extends.py @@ -0,0 +1,45 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from .utils import is_non_field + + +def extends( + graphene_type=None, + *, + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Indicates that an object or interface definition is an extension of another definition of that same type. + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#extends + """ + directive = get_directive_from_name("extends", federation_version) + decorator = directive_decorator(directive) + + def wrapper(field_or_type): + if is_non_field(field_or_type): + return decorator(field=None)(field_or_type) + raise TypeError( + "\n".join( + [ + f"\nInvalid Usage of {directive}.", + "Must be applied on a class of ObjectType|InterfaceType", + "Example:", + f"{directive}", + "class Product(graphene.ObjectType)", + "\t...", + ] + ) + ) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/external.py b/graphene_federation/directives/external.py new file mode 100644 index 0000000..53c56a0 --- /dev/null +++ b/graphene_federation/directives/external.py @@ -0,0 +1,38 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from .utils import is_non_field + + +def external( + graphene_type=None, + *, + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Indicates that this subgraph usually can't resolve a particular object field, + but it still needs to define that field for other purposes. + + This directive is always used in combination with another directive that references object fields, + such as @provides or @requires. + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#external + """ + directive = get_directive_from_name("external", federation_version) + decorator = directive_decorator(directive) + + def wrapper(field_or_type): + if is_non_field(field_or_type): + return decorator(field=None)(field_or_type) + return decorator(field=field_or_type) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/inaccessible.py b/graphene_federation/directives/inaccessible.py new file mode 100644 index 0000000..2c0bb5d --- /dev/null +++ b/graphene_federation/directives/inaccessible.py @@ -0,0 +1,37 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from .utils import is_non_field + + +def inaccessible( + graphene_type=None, + *, + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Indicates that a definition in the subgraph schema should be omitted from the router's API schema, + even if that definition is also present in other subgraphs. + + This means that the field is not exposed to clients at all. + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#inaccessible + """ + directive = get_directive_from_name("inaccessible", federation_version) + decorator = directive_decorator(directive) + + def wrapper(field_or_type): + if is_non_field(field_or_type): + return decorator(field=None)(field_or_type) + return decorator(field=field_or_type) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/interface_object.py b/graphene_federation/directives/interface_object.py new file mode 100644 index 0000000..92a66b3 --- /dev/null +++ b/graphene_federation/directives/interface_object.py @@ -0,0 +1,49 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from .utils import is_non_field + + +def interface_object( + graphene_type=None, + *, + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + 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. + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#interfaceobject + """ + + directive = get_directive_from_name("interfaceObject", federation_version) + decorator = directive_decorator(directive) + + def wrapper(field_or_type): + if is_non_field(field_or_type): + return decorator(field=None)(field_or_type) + raise TypeError( + "\n".join( + [ + f"\nInvalid Usage of {directive}.", + "Must be applied on a class of ObjectType", + "Example:", + f"{directive}", + "class Product(graphene.ObjectType)", + "\t...", + ] + ) + ) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/key.py b/graphene_federation/directives/key.py new file mode 100644 index 0000000..1f7b812 --- /dev/null +++ b/graphene_federation/directives/key.py @@ -0,0 +1,61 @@ +from typing import Any, Union + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from graphene_federation.validators import InternalNamespace, ast_to_str, build_ast +from .utils import is_non_field + + +def key( + fields: Union[str, list[str]], + resolvable: bool = None, + *, + auto_case: bool = True, + federation_version: FederationVersion = LATEST_VERSION, +) -> Any: + """ + Designates an object type as an entity and specifies its key fields + (a set of fields that the subgraph can use to uniquely identify any instance of the entity). + + You can apply multiple @key directives to a single entity (to specify multiple valid sets of key fields) + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#key + """ + directive = get_directive_from_name("key", federation_version) + decorator = directive_decorator(directive) + fields = ast_to_str( + build_ast( + fields=fields if isinstance(fields, str) else " ".join(fields), + directive_name=str(directive), + ) + ) + + if not auto_case: + fields = f"{InternalNamespace.NO_AUTO_CASE.value} {fields}" + + def wrapper(field_or_type): + if is_non_field(field_or_type): + return decorator( + field=None, + fields=fields, + resolvable=resolvable, + )(field_or_type) + raise TypeError( + "\n".join( + [ + f"\nInvalid Usage of {directive}.", + "Must be applied on a class of ObjectType|InterfaceType", + "Example:", + f"{directive}", + "class Product(graphene.ObjectType)", + "\t...", + ] + ) + ) + + return wrapper diff --git a/graphene_federation/directives/override.py b/graphene_federation/directives/override.py new file mode 100644 index 0000000..ce33447 --- /dev/null +++ b/graphene_federation/directives/override.py @@ -0,0 +1,49 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from .utils import is_non_field + + +def override( + graphene_type, + from_: str, + label: str = None, + *, + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Indicates that an object field is now resolved by this subgraph instead of another subgraph where it's also defined. + This enables you to migrate a field from one subgraph to another. + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#override + """ + directive = get_directive_from_name( + "override", federation_version=federation_version + ) + decorator = directive_decorator(directive) + + def wrapper(field_or_type): + if is_non_field(field_or_type): + raise TypeError( + "\n".join( + [ + f"\nInvalid Usage of {directive}.", + "Must be applied on a field level", + "Example:", + "class Product(graphene.ObjectType)", + '\tname = override(graphene.Int(),from="Products")', + ] + ) + ) + return decorator(field=field_or_type, **{"from": from_, "label": label}) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/policy.py b/graphene_federation/directives/policy.py new file mode 100644 index 0000000..203306d --- /dev/null +++ b/graphene_federation/directives/policy.py @@ -0,0 +1,36 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from .utils import is_non_field + + +def policy( + graphene_type=None, + *, + policies: list[list[str]], + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Indicates to composition that the target element is restricted based on authorization policies + that are evaluated in a Rhai script or coprocessor. + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#policy + """ + directive = get_directive_from_name("policy", federation_version=federation_version) + decorator = directive_decorator(directive) + + def wrapper(field_or_type): + if is_non_field(field_or_type): + return decorator(field=None, policies=policies)(field_or_type) + return decorator(field=field_or_type, policies=policies) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/provides.py b/graphene_federation/directives/provides.py new file mode 100644 index 0000000..dc107b3 --- /dev/null +++ b/graphene_federation/directives/provides.py @@ -0,0 +1,66 @@ +from typing import Callable +from typing import Union + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from graphene_federation.validators import InternalNamespace, ast_to_str, build_ast +from .utils import is_non_field + + +def provides( + graphene_type, + fields: Union[str, list[str]], + *, + auto_case: bool = True, + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Specifies a set of entity fields that a subgraph can resolve, but only at a particular schema path + (at other paths, the subgraph can't resolve those fields). + + If a subgraph can always resolve a particular entity field, do not apply this directive. + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#provides + """ + + directive = get_directive_from_name( + "provides", federation_version=federation_version + ) + decorator = directive_decorator(directive) + fields = ast_to_str( + build_ast( + fields=fields if isinstance(fields, str) else " ".join(fields), + directive_name=str(directive), + ) + ) + + if not auto_case: + fields = f"{InternalNamespace.NO_AUTO_CASE.value} {fields}" + + def wrapper(field_or_type): + if is_non_field(field_or_type): + raise TypeError( + "\n".join( + [ + f"\nInvalid Usage of {directive}.", + "Must be applied on a field level", + "Example:", + "class Product(graphene.ObjectType)", + '\torders = provides(graphene.List(Order),fields="id")', + ] + ) + ) + return decorator( + field=field_or_type, + fields=fields, + ) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/requires.py b/graphene_federation/directives/requires.py new file mode 100644 index 0000000..3910916 --- /dev/null +++ b/graphene_federation/directives/requires.py @@ -0,0 +1,68 @@ +from typing import Callable +from typing import Union + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from graphene_federation.validators import InternalNamespace, ast_to_str, build_ast +from .utils import is_non_field + + +def requires( + graphene_type, + fields: Union[str, list[str]], + *, + auto_case: bool = True, + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Indicates that the resolver for a particular entity field depends on the values of other entity fields + that are resolved by other subgraphs. + + This tells the router that it needs to fetch the values of those externally defined fields first, + even if the original client query didn't request them. + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#requires + """ + directive = get_directive_from_name("requires", federation_version) + decorator = directive_decorator(directive) + fields = ast_to_str( + build_ast( + fields=fields if isinstance(fields, str) else " ".join(fields), + directive_name=str(directive), + ), + add_type_name=True, # When resolvers receive the data, it will be type-casted as __typename info is added + ) + + if not auto_case: + fields = f"{InternalNamespace.NO_AUTO_CASE.value} {fields}" + + def wrapper(field_or_type): + if is_non_field(field_or_type): + raise TypeError( + "\n".join( + [ + f"\nInvalid Usage of {directive}.", + "Must be applied on a field level", + "Example:", + "class Product(graphene.ObjectType)", + "\tid = graphene.ID()", + "\torders = graphene.List(Order)" + '\torder_count = requires(graphene.Int(),fields="id orders { id }")', + ] + ) + ) + return decorator( + field=field_or_type, + fields=fields, + auto_case=auto_case, + ) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/requires_scopes.py b/graphene_federation/directives/requires_scopes.py new file mode 100644 index 0000000..5424df8 --- /dev/null +++ b/graphene_federation/directives/requires_scopes.py @@ -0,0 +1,38 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from .utils import is_non_field + + +def requires_scope( + graphene_type=None, + *, + scopes: list[list[str]], + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Indicates to composition that the target element is accessible only to the authenticated supergraph users with + the appropriate JWT scopes. + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#requiresscopes + """ + directive = get_directive_from_name( + "requiresScopes", federation_version=federation_version + ) + decorator = directive_decorator(directive) + + def wrapper(field_or_type): + if is_non_field(field_or_type): + return decorator(field=None, scopes=scopes)(field_or_type) + return decorator(field=field_or_type, scopes=scopes) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/shareable.py b/graphene_federation/directives/shareable.py new file mode 100644 index 0000000..ab58fcb --- /dev/null +++ b/graphene_federation/directives/shareable.py @@ -0,0 +1,40 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from .utils import is_non_field + + +def shareable( + graphene_type=None, + *, + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Indicates that an object type's field is allowed to be resolved by multiple subgraphs + (by default in Federation 2, object fields can be resolved by only one subgraph). + + If applied to an object type definition, all of that type's fields are considered @shareable + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#shareable + """ + + directive = get_directive_from_name( + "shareable", federation_version=federation_version + ) + decorator = directive_decorator(directive) + + def wrapper(field_or_type): + if is_non_field(field_or_type): + return decorator(field=None)(field_or_type) + return decorator(field=field_or_type) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/tag.py b/graphene_federation/directives/tag.py new file mode 100644 index 0000000..7ecf821 --- /dev/null +++ b/graphene_federation/directives/tag.py @@ -0,0 +1,37 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) +from .utils import is_non_field + + +def tag( + graphene_type=None, + *, + name: str, + federation_version: FederationVersion = LATEST_VERSION, +) -> Callable: + """ + Applies arbitrary string metadata to a schema location. + Custom tooling can use this metadata during any step of the schema delivery flow, + including composition, static analysis, and documentation + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#tag + """ + directive = get_directive_from_name("tag", federation_version=federation_version) + decorator = directive_decorator(directive) + + def wrapper(field_or_type): + if is_non_field(field_or_type): + return decorator(field=None, name=name)(field_or_type) + return decorator(field=field_or_type, name=name) + + if graphene_type: + return wrapper(graphene_type) + + return wrapper diff --git a/graphene_federation/directives/utils.py b/graphene_federation/directives/utils.py new file mode 100644 index 0000000..378a5a5 --- /dev/null +++ b/graphene_federation/directives/utils.py @@ -0,0 +1,7 @@ +import inspect +from typing import Any + + +def is_non_field(graphene_type: Any): + """Check of a given graphene_type is a non-field""" + return inspect.isclass(graphene_type) diff --git a/graphene_federation/entity.py b/graphene_federation/entity.py index e99cff9..74330d7 100644 --- a/graphene_federation/entity.py +++ b/graphene_federation/entity.py @@ -1,27 +1,15 @@ from __future__ import annotations -import collections.abc -from typing import Any, Callable, Dict +from typing import Any +from typing import Dict, Type -from graphene import Field, List, NonNull, ObjectType, Union -from graphene.types.schema import Schema +from graphene import Enum, Field, List, NonNull, ObjectType, Scalar, Union from graphene.types.schema import TypeMap +from graphene_directives import Schema +from graphene_directives.utils import has_non_field_attribute -from .types import _Any -from .utils import ( - check_fields_exist_on_type, - field_name_to_type_attribute, - is_valid_compound_key, -) - - -def update(d, u): - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - d[k] = update(d.get(k, {}), v) - else: - d[k] = v - return d +from .apollo_versions import LATEST_VERSION, get_directive_from_name +from .scalars import _Any def get_entities(schema: Schema) -> Dict[str, Any]: @@ -32,25 +20,27 @@ def get_entities(schema: Schema) -> Dict[str, Any]: """ type_map: TypeMap = schema.graphql_schema.type_map entities = {} + key_directive = get_directive_from_name("key", LATEST_VERSION) + extends_directive = get_directive_from_name("extends", LATEST_VERSION) for type_name, type_ in type_map.items(): if not hasattr(type_, "graphene_type"): continue - if getattr(type_.graphene_type, "_keys", None): - entities[type_name] = type_.graphene_type - - # Validation for compound keys - key_str = " ".join(type_.graphene_type._keys) - type_name = type_.graphene_type._meta.name - if "{" in key_str: # checking for subselection to identify compound key - assert is_valid_compound_key( - type_name, key_str, schema - ), f'Invalid compound key definition for type "{type_name}"' + + graphene_type = type_.graphene_type + is_entity = any( + [ + has_non_field_attribute(graphene_type, key_directive), + has_non_field_attribute(graphene_type, extends_directive), + ] + ) + if is_entity: + entities[type_name] = graphene_type return entities -def get_entity_cls(entities: Dict[str, Any]) -> Union: +def get_entity_cls(entities: Dict[str, Any]) -> Type[Union]: """ - Create _Entity type which is a union of all the entities types. + Create _Entity type which is a union of all the entity types. """ class _Entity(Union): @@ -86,12 +76,13 @@ def resolve_entities(self, info, representations, sub_field_resolution=False): model_arguments = representation.copy() model_arguments.pop("__typename") if schema.auto_camelcase: - get_model_attr = field_name_to_type_attribute(schema, model) + get_model_attr = schema.field_name_to_type_attribute(model) model_arguments = { get_model_attr(k): v for k, v in model_arguments.items() } - # convert subfields of models from dict to a corresponding graphql type + # convert subfields of models from dict to a corresponding graphql type, + # This will be useful when @requires is used for model_field, value in model_arguments.items(): if not hasattr(model, model_field): continue @@ -99,7 +90,7 @@ def resolve_entities(self, info, representations, sub_field_resolution=False): field = getattr(model, model_field) if isinstance(field, Field) and isinstance(value, dict): if value.get("__typename") is None: - value["__typename"] = field.type.of_type._meta.name + value["__typename"] = field.type.of_type._meta.name # noqa model_arguments[model_field] = EntityQuery.resolve_entities( self, info, @@ -126,50 +117,30 @@ def resolve_entities(self, info, representations, sub_field_resolution=False): ): for sub_value in value: if sub_value.get("__typename") is None: - sub_value["__typename"] = field.of_type._meta.name + sub_value[ + "__typename" + ] = field.of_type._meta.name # noqa model_arguments[model_field] = EntityQuery.resolve_entities( self, info, representations=value, sub_field_resolution=True ) + elif isinstance(field, Scalar) and getattr( + field, "parse_value", None + ): + model_arguments[model_field] = field.parse_value(value) + elif isinstance(field, Enum): + model_arguments[model_field] = field._meta.enum[value] # noqa model_instance = model(**model_arguments) resolver = getattr( model, "_%s__resolve_reference" % model.__name__, None ) or getattr(model, "_resolve_reference", None) + if resolver and not sub_field_resolution: model_instance = resolver(model_instance, info) entities.append(model_instance) + return entities return EntityQuery - - -def key(fields: str, resolvable: bool = True) -> Callable: - """ - Take as input a field that should be used as key for that entity. - See specification: https://www.apollographql.com/docs/federation/federation-spec/#key - """ - - def decorator(type_): - # Check the provided fields actually exist on the Type. - if " " not in fields: - assert ( - fields in type_._meta.fields - ), f'Field "{fields}" does not exist on type "{type_._meta.name}"' - if "{" not in fields: - # Skip valid fields check if the key is a compound key. The validation for compound keys - # is done on calling get_entities() - fields_set = set(fields.split(" ")) - assert check_fields_exist_on_type( - fields=fields_set, type_=type_ - ), f'Field "{fields}" does not exist on type "{type_._meta.name}"' - - keys = getattr(type_, "_keys", []) - keys.append(fields) - setattr(type_, "_keys", keys) - setattr(type_, "_resolvable", resolvable) - - return type_ - - return decorator diff --git a/graphene_federation/extend.py b/graphene_federation/extend.py deleted file mode 100644 index c6fb972..0000000 --- a/graphene_federation/extend.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import Any, Callable, Dict - -from graphene import Schema - -from graphene_federation.utils import check_fields_exist_on_type, is_valid_compound_key - - -def get_extended_types(schema: Schema) -> Dict[str, Any]: - """ - Find all the extended types from the schema. - They can be easily distinguished from the other type as - the `@extend` decorator adds a `_extended` attribute to them. - """ - extended_types = {} - for type_name, type_ in schema.graphql_schema.type_map.items(): - if not hasattr(type_, "graphene_type"): - continue - if getattr(type_.graphene_type, "_extended", False): - extended_types[type_name] = type_.graphene_type - - # Validation for compound keys - key_str = " ".join(type_.graphene_type._keys) - type_name = type_.graphene_type._meta.name - if "{" in key_str: # checking for subselection to identify compound key - assert is_valid_compound_key( - type_name, key_str, schema - ), f'Invalid compound key definition for type "{type_name}"' - return extended_types - - -def extend(fields: str) -> Callable: - """ - Decorator to use to extend a given type. - The field to extend must be provided as input as a string. - """ - - def decorator(type_): - assert not hasattr( - type_, "_keys" - ), "Can't extend type which is already extended or has @key" - # Check the provided fields actually exist on the Type. - - if "{" not in fields: # Check for compound keys - # Skip valid fields check if the key is a compound key. The validation for compound keys - # is done on calling get_extended_types() - fields_set = set(fields.split(" ")) - assert check_fields_exist_on_type( - fields=fields_set, type_=type_ - ), f'Field "{fields}" does not exist on type "{type_._meta.name}"' - - assert getattr(type_._meta, "description", None) is None, ( - f'Type "{type_.__name__}" has a non empty description and it is also marked with extend.' - "\nThey are mutually exclusive." - "\nSee https://github.com/graphql/graphql-js/issues/2385#issuecomment-577997521" - ) - # Set a `_keys` attribute so it will be registered as an entity - setattr(type_, "_keys", [fields]) - # Set a `_extended` attribute to be able to distinguish it from the other entities - setattr(type_, "_extended", True) - return type_ - - return decorator diff --git a/graphene_federation/external.py b/graphene_federation/external.py deleted file mode 100644 index 89b6713..0000000 --- a/graphene_federation/external.py +++ /dev/null @@ -1,41 +0,0 @@ -from graphene import Schema -from graphene.types.objecttype import ObjectTypeMeta - -from graphene_federation.utils import get_attributed_fields - - -def external(field): - """ - Mark a field as external. - """ - if isinstance(field, ObjectTypeMeta): - field._external_entity = True - else: - field._external = True - return field - - -def get_external_fields(schema: Schema) -> dict: - """ - Find all the extended types from the schema. - They can be easily distinguished from the other type as - the `@external` decorator adds a `_external` attribute to them. - """ - return get_attributed_fields(attribute="_external", schema=schema) - - -def get_external_object_types(schema: Schema) -> dict: - """ - Find all the extended object types from the schema. - They can be easily distinguished from the other type as - the `@external` decorator adds a `_external_entity` attribute to them. - """ - fields = {} - - for type_name, type_ in schema.graphql_schema.type_map.items(): - if hasattr(type_, "graphene_type") and hasattr( - type_.graphene_type, "_external_entity" - ): - fields[type_name] = type_ - - return fields diff --git a/graphene_federation/inaccessible.py b/graphene_federation/inaccessible.py deleted file mode 100644 index fffc44a..0000000 --- a/graphene_federation/inaccessible.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any, Dict, Optional - -from graphene import Schema - -from graphene_federation.utils import get_attributed_fields - - -def get_inaccessible_types(schema: Schema) -> Dict[str, Any]: - """ - Find all the inaccessible types from the schema. - They can be easily distinguished from the other type as - the `@inaccessible` decorator adds a `_inaccessible` attribute to them. - """ - inaccessible_types = {} - for type_name, type_ in schema.graphql_schema.type_map.items(): - if not hasattr(type_, "graphene_type"): - continue - if getattr(type_.graphene_type, "_inaccessible", False): - inaccessible_types[type_name] = type_.graphene_type - return inaccessible_types - - -def inaccessible(field: Optional[Any] = None) -> Any: - """ - Decorator to use to inaccessible a given type. - """ - - # noinspection PyProtectedMember,PyPep8Naming - def decorator(field_or_type): - # TODO Check the provided fields actually exist on the Type. - # Set a `_inaccessible` attribute to be able to distinguish it from the other entities - setattr(field_or_type, "_inaccessible", True) - return field_or_type - - if field: - return decorator(field) - return decorator - - -def get_inaccessible_fields(schema: Schema) -> dict: - """ - Find all the inacessible types from the schema. - They can be easily distinguished from the other type as - the `@inaccessible` decorator adds a `_inaccessible` attribute to them. - """ - return get_attributed_fields(attribute="_inaccessible", schema=schema) diff --git a/graphene_federation/main.py b/graphene_federation/main.py deleted file mode 100644 index 87ad5ca..0000000 --- a/graphene_federation/main.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Optional - -from graphene import Schema -from graphene import ObjectType - -from .entity import get_entity_query -from .service import get_service_query - - -def _get_query(schema: Schema, query_cls: Optional[ObjectType] = None) -> ObjectType: - type_name = "Query" - bases = [get_service_query(schema)] - entity_cls = get_entity_query(schema) - if entity_cls: - bases.append(entity_cls) - if query_cls is not None: - type_name = query_cls.__name__ - bases.append(query_cls) - federated_query_cls = type(type_name, tuple(bases), {}) - return federated_query_cls - - -def build_schema( - query: Optional[ObjectType] = None, - mutation: Optional[ObjectType] = None, - federation_version: Optional[float] = None, - enable_federation_2: bool = False, - schema: Optional[Schema] = None, - **kwargs -) -> Schema: - schema = schema or Schema(query=query, mutation=mutation, **kwargs) - schema.auto_camelcase = kwargs.get("auto_camelcase", True) - schema.federation_version = float( - (federation_version or 2) if (enable_federation_2 or federation_version) else 1 - ) - federation_query = _get_query(schema, schema.query) - # Use shallow copy to prevent recursion error - kwargs = schema.__dict__.copy() - kwargs.pop("query") - kwargs.pop("graphql_schema") - kwargs.pop("federation_version") - return type(schema)(query=federation_query, **kwargs) diff --git a/graphene_federation/override.py b/graphene_federation/override.py deleted file mode 100644 index fb8dac8..0000000 --- a/graphene_federation/override.py +++ /dev/null @@ -1,20 +0,0 @@ -from graphene import Schema - -from graphene_federation.utils import get_attributed_fields - - -def override(field, from_: str): - """ - Decorator to use to override a given type. - """ - field._override = from_ - return field - - -def get_override_fields(schema: Schema) -> dict: - """ - Find all the overridden types from the schema. - They can be easily distinguished from the other type as - the `@override` decorator adds a `_override` attribute to them. - """ - return get_attributed_fields(attribute="_override", schema=schema) diff --git a/graphene_federation/provides.py b/graphene_federation/provides.py deleted file mode 100644 index ee59bc7..0000000 --- a/graphene_federation/provides.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import Any, Union, Dict, List - -from graphene import Field -from graphene import Schema - -from graphene_federation.utils import get_attributed_fields - - -def get_provides_parent_types(schema: Schema) -> Dict[str, Any]: - """ - Find all the types for which a field is provided from the schema. - They can be easily distinguished from the other type as - the `@provides` decorator used on the type itself adds a `_provide_parent_type` attribute to them. - """ - provides_parent_types = {} - for type_name, type_ in schema.graphql_schema.type_map.items(): - if not hasattr(type_, "graphene_type"): - continue - if getattr(type_.graphene_type, "_provide_parent_type", False): - provides_parent_types[type_name] = type_.graphene_type - return provides_parent_types - - -def provides(field, fields: Union[str, List[str]] = None): - """ - - :param field: base type (when used as decorator) or field of base type - :param fields: - :return: - """ - if fields is None: # used as decorator on base type - if isinstance(field, Field): - raise ValueError("Please specify fields") - field._provide_parent_type = True - else: # used as wrapper over field - # TODO: We should validate the `fields` input to check it is actually existing fields but we - # don't have access here to the graphene type of the object it provides those fields for. - if isinstance(fields, str): - fields = fields.split() - field._provides = fields - return field - - -def get_provides_fields(schema: Schema) -> dict: - """ - Find all the extended types from the schema. - They can be easily distinguished from the other type as - the `@provides` decorator adds a `_provides` attribute to them. - """ - return get_attributed_fields(attribute="_provides", schema=schema) diff --git a/graphene_federation/requires.py b/graphene_federation/requires.py deleted file mode 100644 index e270e5b..0000000 --- a/graphene_federation/requires.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import List, Union - -from graphene import Schema - -from graphene_federation.utils import get_attributed_fields - - -def requires(field, fields: Union[str, List[str]]): - """ - Mark the required fields for a given field. - The input `fields` can be either a string or a list. - When it is a string we split at spaces to get the list of fields. - """ - # TODO: We should validate the `fields` input to check it is actually existing fields but we - # don't have access here to the parent graphene type. - if isinstance(fields, str): - fields = fields.split() - assert not hasattr( - field, "_requires" - ), "Can't chain `requires()` method calls on one field." - field._requires = fields - return field - - -def get_required_fields(schema: Schema) -> dict: - """ - Find all the extended types with required fields from the schema. - They can be easily distinguished from the other type as - the `@requires` decorator adds a `_requires` attribute to them. - """ - return get_attributed_fields(attribute="_requires", schema=schema) diff --git a/graphene_federation/scalars/__init__.py b/graphene_federation/scalars/__init__.py new file mode 100644 index 0000000..3bb899a --- /dev/null +++ b/graphene_federation/scalars/__init__.py @@ -0,0 +1,7 @@ +from ._any import _Any +from .federation_policy import FederationPolicy +from .federation_scope import FederationScope +from .field_set_v1 import _FieldSet +from .field_set_v2 import FieldSet +from .link_import import link_import +from .link_purpose import link_purpose diff --git a/graphene_federation/types.py b/graphene_federation/scalars/_any.py similarity index 60% rename from graphene_federation/types.py rename to graphene_federation/scalars/_any.py index 65d7dea..06e3844 100644 --- a/graphene_federation/types.py +++ b/graphene_federation/scalars/_any.py @@ -1,8 +1,12 @@ from graphene import Scalar, String +# Reference: https://www.apollographql.com/docs/federation/subgraph-spec/ class _Any(Scalar): + name = "_Any" __typename = String(required=True) + description = "A JSON serialized used for entity representations" + specified_by_url = None @staticmethod def serialize(dt): diff --git a/graphene_federation/scalars/federation_policy.py b/graphene_federation/scalars/federation_policy.py new file mode 100644 index 0000000..d4934ed --- /dev/null +++ b/graphene_federation/scalars/federation_policy.py @@ -0,0 +1,53 @@ +from typing import Any + +from graphql import ( + GraphQLError, + GraphQLScalarType, + StringValueNode, + ValueNode, + print_ast, +) +from graphql.pyutils import inspect + + +def _serialize_string(output_value: Any) -> str: + if isinstance(output_value, str): + return output_value + # do not serialize builtin types as strings, but allow serialization of custom + # types via their `__str__` method + if type(output_value).__module__ == "builtins": + raise GraphQLError( + "federation__Policy cannot represent value: " + inspect(output_value) + ) + + return str(output_value) + + +def _coerce_string(input_value: Any) -> str: + if not isinstance(input_value, str): + raise GraphQLError( + "federation__Policy cannot represent a non string value: " + + inspect(input_value) + ) + return input_value + + +def _parse_string_literal(value_node: ValueNode, _variables: Any = None) -> str: + """Parse a string value node in the AST.""" + if not isinstance(value_node, StringValueNode): + raise GraphQLError( + "federation__Policy cannot represent a non string value: " + + print_ast(value_node), + value_node, + ) + return value_node.value + + +# Reference: https://www.apollographql.com/docs/federation/subgraph-spec/ +FederationPolicy = GraphQLScalarType( + name="federation__Policy", + description="This string-serialized scalar represents an authorization policy.", + serialize=_serialize_string, + parse_value=_coerce_string, + parse_literal=_parse_string_literal, +) diff --git a/graphene_federation/scalars/federation_scope.py b/graphene_federation/scalars/federation_scope.py new file mode 100644 index 0000000..0ce7c9c --- /dev/null +++ b/graphene_federation/scalars/federation_scope.py @@ -0,0 +1,49 @@ +from typing import Any + +from graphql import ( + GraphQLError, + GraphQLScalarType, + StringValueNode, + ValueNode, + print_ast, +) +from graphql.pyutils import inspect + + +def _serialize_string(output_value: Any) -> str: + if isinstance(output_value, str): + return output_value + # do not serialize builtin types as strings, but allow serialization of custom + # types via their `__str__` method + if type(output_value).__module__ == "builtins": + raise GraphQLError("Scope cannot represent value: " + inspect(output_value)) + + return str(output_value) + + +def _coerce_string(input_value: Any) -> str: + if not isinstance(input_value, str): + raise GraphQLError( + "Scope cannot represent a non string value: " + inspect(input_value) + ) + return input_value + + +def _parse_string_literal(value_node: ValueNode, _variables: Any = None) -> str: + """Parse a string value node in the AST.""" + if not isinstance(value_node, StringValueNode): + raise GraphQLError( + "Scope cannot represent a non string value: " + print_ast(value_node), + value_node, + ) + return value_node.value + + +# Reference: https://www.apollographql.com/docs/federation/subgraph-spec/ +FederationScope = GraphQLScalarType( + name="federation__Scope", + description="This string-serialized scalar represents a JWT scope", + serialize=_serialize_string, + parse_value=_coerce_string, + parse_literal=_parse_string_literal, +) diff --git a/graphene_federation/scalars/field_set_v1.py b/graphene_federation/scalars/field_set_v1.py new file mode 100644 index 0000000..1dee96d --- /dev/null +++ b/graphene_federation/scalars/field_set_v1.py @@ -0,0 +1,12 @@ +from graphql import GraphQLScalarType + +# Reference: https://www.apollographql.com/docs/federation/subgraph-spec/ +_FieldSet = GraphQLScalarType( + name="_FieldSet", + description=" ".join( + ( + "A string-serialized scalar represents a set of fields that's passed to a federated directive,", + "such as @key, @requires, or @provides", + ) + ), +) diff --git a/graphene_federation/scalars/field_set_v2.py b/graphene_federation/scalars/field_set_v2.py new file mode 100644 index 0000000..5c6b900 --- /dev/null +++ b/graphene_federation/scalars/field_set_v2.py @@ -0,0 +1,12 @@ +from graphql import GraphQLScalarType + +# Reference: https://www.apollographql.com/docs/federation/subgraph-spec/ +FieldSet = GraphQLScalarType( + name="FieldSet", + description=" ".join( + ( + "A string-serialized scalar represents a set of fields that's passed to a federated directive,", + "such as @key, @requires, or @provides", + ) + ), +) diff --git a/graphene_federation/scalars/link_import.py b/graphene_federation/scalars/link_import.py new file mode 100644 index 0000000..ac2a88c --- /dev/null +++ b/graphene_federation/scalars/link_import.py @@ -0,0 +1,63 @@ +from typing import Any + +from graphql import ( + GraphQLError, + GraphQLScalarType, + StringValueNode, + ValueNode, + print_ast, +) +from graphql.pyutils import inspect +from math import isfinite + + +def _serialize_string(output_value: Any) -> str: + if isinstance(output_value, str): + return output_value + if isinstance(output_value, bool): + return "true" if output_value else "false" + if isinstance(output_value, int) or ( + isinstance(output_value, float) and isfinite(output_value) + ): + return str(output_value) + # do not serialize builtin types as strings, but allow serialization of custom + # types via their `__str__` method + if type(output_value).__module__ == "builtins": + raise GraphQLError( + "link__Import cannot represent value: " + inspect(output_value) + ) + + return str(output_value) + + +def _coerce_string(input_value: Any) -> str: + if not isinstance(input_value, str): + raise GraphQLError( + "link__Import cannot represent a non string value: " + inspect(input_value) + ) + return input_value + + +def _parse_string_literal(value_node: ValueNode, _variables: Any = None) -> str: + """Parse a string value node in the AST.""" + if not isinstance(value_node, StringValueNode): + raise GraphQLError( + "link__Import cannot represent a non string value: " + + print_ast(value_node), + value_node, + ) + return value_node.value + + +link_import = GraphQLScalarType( + name="link__Import", + description=" ".join( + ( + "A string serialized scalar specify which directives from an external federation specification", + "should be imported into the current schema when using @link", + ) + ), + serialize=_serialize_string, + parse_value=_coerce_string, + parse_literal=_parse_string_literal, +) diff --git a/graphene_federation/scalars/link_purpose.py b/graphene_federation/scalars/link_purpose.py new file mode 100644 index 0000000..c9a36df --- /dev/null +++ b/graphene_federation/scalars/link_purpose.py @@ -0,0 +1,18 @@ +from graphql import GraphQLEnumType, GraphQLEnumValue + +# Reference: https://www.apollographql.com/docs/federation/subgraph-spec/ + +link_purpose = GraphQLEnumType( + name="link__Purpose", + description="An Enum to clarify the type of directives (security, execution) in the specification", + values={ + "SECURITY": GraphQLEnumValue( + value="SECURITY", + description="`SECURITY` features provide metadata necessary to securely resolve fields.", + ), + "EXECUTION": GraphQLEnumValue( + value="EXECUTION", + description="`EXECUTION` features provide metadata necessary for operation execution.", + ), + }, +) diff --git a/graphene_federation/schema.py b/graphene_federation/schema.py new file mode 100644 index 0000000..62c96fb --- /dev/null +++ b/graphene_federation/schema.py @@ -0,0 +1,203 @@ +from typing import Collection, Type, Union +from typing import Optional + +from graphene import ObjectType, PageInfo +from graphene_directives import ( + SchemaDirective, + build_schema as build_directive_schema, + directive_decorator, +) +from graphene_directives.schema import Schema + +from .apollo_versions import ( + FederationVersion, + STABLE_VERSION, + get_directive_from_name, + get_directives_based_on_version, +) +from .apollo_versions.v2_1 import compose_directive as ComposeDirective +from .composable_directive import ComposableDirective +from .entity import get_entity_query +from .schema_directives import compose_directive, link_directive +from .service import get_service_query + + +def _get_federation_query( + schema: Schema, query_cls: Optional[ObjectType] = None +) -> Type[ObjectType]: + """ + Add Federation required _service and _entities to Query(ObjectType) + """ + type_name = "Query" + bases = [get_service_query(schema)] + entity_cls = get_entity_query(schema) + if entity_cls: + bases.append(entity_cls) + if query_cls is not None: + type_name = query_cls.__name__ + bases.append(query_cls) + federated_query_cls = type(type_name, tuple(bases), {}) + return federated_query_cls # noqa + + +def _add_sharable_to_page_info_type( + schema: Schema, + federation_version: FederationVersion, + types: list[Union[ObjectType, Type[ObjectType]]], +): + """ + Add @sharable directive to PageInfo type + """ + + if page_info := schema.graphql_schema.type_map.get(PageInfo.__name__): + try: + # PageInfo needs @sharable directive + sharable = get_directive_from_name("shareable", federation_version) + types.append( + directive_decorator(target_directive=sharable)(field=None)( + page_info.graphene_type + ) + ) + except ValueError: + # Federation Version does not support @sharable + pass + + +def build_schema( + query: Union[ObjectType, Type[ObjectType]] = None, + mutation: Union[ObjectType, Type[ObjectType]] = None, + subscription: Union[ObjectType, Type[ObjectType]] = None, + types: Collection[Union[ObjectType, Type[ObjectType]]] = None, + directives: Union[Collection[ComposableDirective], None] = None, + include_graphql_spec_directives: bool = True, + schema_directives: Collection[SchemaDirective] = None, + auto_camelcase: bool = True, + federation_version: FederationVersion = None, +) -> Schema: + """ + Build Schema. + + Args: + query (Type[ObjectType]): Root query *ObjectType*. Describes entry point for fields to *read* + data in your Schema. + mutation (Optional[Type[ObjectType]]): Root mutation *ObjectType*. Describes entry point for + fields to *create, update or delete* data in your API. + subscription (Optional[Type[ObjectType]]): Root subscription *ObjectType*. Describes entry point + for fields to receive continuous updates. + types (Optional[Collection[Type[ObjectType]]]): List of any types to include in schema that + may not be introspected through root types. + directives (List[GraphQLDirective], optional): List of custom directives to include in the + GraphQL schema. + auto_camelcase (bool): Fieldnames will be transformed in Schema's TypeMap from snake_case + to camelCase (preferred by GraphQL standard). Default True. + schema_directives (Collection[SchemaDirective]): Directives that can be defined at DIRECTIVE_LOCATION.SCHEMA + with their argument values. + include_graphql_spec_directives (bool): Includes directives defined by GraphQL spec (@include, @skip, + @deprecated, @specifiedBy) + federation_version (FederationVersion): Specify the version explicit (default STABLE_VERSION) + """ + + federation_version = federation_version if federation_version else STABLE_VERSION + federation_2_enabled = ( + federation_version.value > FederationVersion.VERSION_1_0.value + ) + + _types = list(types) if types is not None else [] + + _directives = get_directives_based_on_version(federation_version) + federation_directives = set(_directives.keys()) + if directives is not None: # Add custom directives + _directives.update({directive.name: directive for directive in directives}) + + schema_args = { + "mutation": mutation, + "subscription": subscription, + "types": _types, + "directives": _directives.values(), + "auto_camelcase": auto_camelcase, + "include_graphql_spec_directives": include_graphql_spec_directives, + } + + schema: Schema = build_directive_schema(query=query, **schema_args) + + _add_sharable_to_page_info_type( + schema=schema, federation_version=federation_version, types=_types + ) + + _schema_directives = [] + directives_used = schema.get_directives_used() + if schema_directives or directives: + if not federation_2_enabled: + raise ValueError( + f"Schema Directives & Directives are not supported on {federation_version=}. Use >=2.0 " + ) + + # Check if @ComposeDirective needs to be added to schema + if ( + any( + schema_directive.target_directive == ComposeDirective + for schema_directive in schema_directives or [] + ) + or directives + ): + directives_used.append(ComposeDirective) + + if directives_used and federation_2_enabled: + imports = [ + str(directive) + for directive in directives_used + if directive.name in federation_directives + ] + if imports: + _schema_directives.append( + link_directive( + url=f"https://specs.apollo.dev/federation/v{federation_version.value}", + import_=sorted(imports), + ) + ) + + # Add @link directive for Custom Directives provided + if directives: + url__imports: dict[str, list[str]] = {} + for directive in directives: + assert isinstance( + directive, ComposableDirective + ), "directives must be of instance ComposableDirective" + + if not directive.add_to_schema_directives: + continue + + if not directive.spec_url: + continue + + _imports = url__imports.get(directive.spec_url) + if _imports: + _imports.append(str(directive)) + else: + url__imports[directive.spec_url] = [str(directive)] + + # Add @link schema directives + for spec, imports in url__imports.items(): + _schema_directives.append(link_directive(url=spec, import_=sorted(imports))) + + # Add @ComposeDirective to schema directives + for directive in directives: + if not directive.add_to_schema_directives: + continue + _schema_directives.append(compose_directive(name=str(directive))) + + if schema_directives: + _schema_directives.extend(list(schema_directives)) + + schema_args["schema_directives"] = ( + _schema_directives if federation_2_enabled else [] + ) + + # Call it again to rebuild the schema using the schema directives + schema = build_directive_schema(query=query, **schema_args) + + # Add Federation required _service and _entities to Query + return build_directive_schema( + query=_get_federation_query(schema, schema.query), + **schema_args, + ) diff --git a/graphene_federation/schema_directives/__init__.py b/graphene_federation/schema_directives/__init__.py new file mode 100644 index 0000000..7339a26 --- /dev/null +++ b/graphene_federation/schema_directives/__init__.py @@ -0,0 +1,2 @@ +from .compose_directive import compose_directive +from .link_directive import link_directive diff --git a/graphene_federation/schema_directives/compose_directive.py b/graphene_federation/schema_directives/compose_directive.py new file mode 100644 index 0000000..8dbcd77 --- /dev/null +++ b/graphene_federation/schema_directives/compose_directive.py @@ -0,0 +1,31 @@ +from graphene_directives import SchemaDirective + +from graphene_federation.apollo_versions import ( + FederationVersion, + LATEST_VERSION, + get_directive_from_name, +) + + +def compose_directive( + name: str, + federation_version: FederationVersion = LATEST_VERSION, +) -> SchemaDirective: + """ + Indicates to composition that all uses of a particular custom type system directive in the subgraph schema should be + preserved in the supergraph schema + + (by default, composition omits most directives from the supergraph schema). + + Use this in the `schema_directives` argument of `build_schema` + + It is not recommended to use this directive directly, instead use the ComposableDirective class to build + a custom directive. It will automatically add the compose and link directive to schema + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/#composedirective + """ + directive = get_directive_from_name("composeDirective", federation_version) + return SchemaDirective( + target_directive=directive, + arguments={"name": name}, + ) diff --git a/graphene_federation/schema_directives/link_directive.py b/graphene_federation/schema_directives/link_directive.py new file mode 100644 index 0000000..8f3e35f --- /dev/null +++ b/graphene_federation/schema_directives/link_directive.py @@ -0,0 +1,52 @@ +from typing import Optional + +from graphene_directives import CustomDirective, DirectiveLocation, SchemaDirective +from graphql import ( + GraphQLArgument, + GraphQLList, + GraphQLNonNull, + GraphQLString, +) + +from graphene_federation.scalars import link_import, link_purpose + +_link_directive = CustomDirective( + name="link", + locations=[ + DirectiveLocation.SCHEMA, + ], + args={ + "url": GraphQLArgument(GraphQLNonNull(GraphQLString)), + "as": GraphQLArgument(GraphQLString), + "for": GraphQLArgument(link_purpose), + "import": GraphQLArgument(GraphQLList(link_import)), + }, + description="Federation @link directive", + add_definition_to_schema=False, + is_repeatable=True, +) + + +def link_directive( + url: str, + as_: Optional[str] = None, + for_: Optional[str] = None, + import_: Optional[list[str]] = None, +) -> SchemaDirective: + """ + It's used to link types and fields from external subgraphs, creating a unified GraphQL schema + across multiple services + + Use this in the `schema_directives` argument of `build_schema` + + It is not recommended to use this directive directly, instead use the ComposableDirective class to build + a custom directive. It will automatically add the compose and link directive to schema + + Also, the apollo directives such as @key, @external, ... are automatically added to the schema via the link directive + + Reference: https://www.apollographql.com/docs/federation/federated-types/federated-directives/ + """ + return SchemaDirective( + target_directive=_link_directive, + arguments={"url": url, "as": as_, "for": for_, "import": import_}, + ) diff --git a/graphene_federation/service.py b/graphene_federation/service.py index 84dbc72..a772648 100644 --- a/graphene_federation/service.py +++ b/graphene_federation/service.py @@ -1,301 +1,32 @@ -import re -from typing import List +from graphene import Field, ObjectType, String +from graphene_directives.schema import Schema -from graphene.types.interface import InterfaceOptions -from graphene.types.union import UnionOptions -from graphql import GraphQLInterfaceType, GraphQLObjectType -from .compose_directive import is_composable, compose_directive_schema_extensions -from .external import get_external_fields, get_external_object_types -from .inaccessible import get_inaccessible_types, get_inaccessible_fields -from .override import get_override_fields -from .requires import get_required_fields -from .shareable import get_shareable_types, get_shareable_fields -from graphql.utilities.print_schema import print_fields - -from graphene import ObjectType, String, Field, Schema - -from .extend import get_extended_types -from .provides import get_provides_parent_types, get_provides_fields - -from .entity import get_entities -from .tag import get_tagged_fields -from .utils import field_name_to_type_attribute, type_attribute_to_field_name - - -class MonoFieldType: - """ - In order to be able to reuse the `print_fields` method to get a singular field - string definition, we need to define an object that has a `.fields` attribute. - """ - - def __init__(self, name, field): - self.fields = {name: field} - - -def convert_fields(schema: Schema, fields: List[str]) -> str: - get_field_name = type_attribute_to_field_name(schema) - return " ".join([get_field_name(field) for field in fields]) - - -def convert_fields_for_requires(schema: Schema, fields: List[str]) -> str: - """ - Adds __typename for resolving union,sub-field types - """ - get_field_name = type_attribute_to_field_name(schema) - new_fields = [] - for field in fields: - if "typename" not in field.lower(): # skip user defined typename - new_fields.append(get_field_name(field)) - if "{" in field: - new_fields.append("__typename") - - return " ".join(new_fields) - - -DECORATORS = { - "_external": lambda schema, fields: "@external", - "_requires": lambda schema, fields: f'@requires(fields: "{convert_fields_for_requires(schema, fields)}")', - "_provides": lambda schema, fields: f'@provides(fields: "{convert_fields(schema, fields)}")', - "_shareable": lambda schema, fields: "@shareable", - "_inaccessible": lambda schema, fields: "@inaccessible", - "_override": lambda schema, from_: f'@override(from: "{from_}")', - "_tag": lambda schema, name: f'@tag(name: "{name}")', -} - - -def field_to_string(field) -> str: - str_field = print_fields(field) - # Remove blocks added by `print_block` - block_match = re.match(r" \{\n(?P.*)\n\}", str_field, flags=re.DOTALL) - if block_match: - str_field = block_match.groups()[0] - return str_field - - -def add_entity_fields_decorators(entity, schema: Schema, string_schema: str) -> str: - """ - For a given entity, go through all its fields and see if any directive decorator need to be added. - The methods (from graphene-federation) marking fields that require some special treatment for federation add - corresponding attributes to the field itself. - Those attributes are listed in the `DECORATORS` variable as key and their respective value is the resolver that - returns what needs to be amended to the field declaration. - - This method simply go through the fields that need to be modified and replace them with their annotated version in the - schema string representation. - """ - entity_name = entity._meta.name - entity_type = schema.graphql_schema.get_type(entity_name) - str_fields = [] - get_model_attr = field_name_to_type_attribute(schema, entity) - for field_name, field in ( - entity_type.fields.items() if getattr(entity_type, "fields", None) else [] - ): - str_field = field_to_string(MonoFieldType(field_name, field)) - # Check if we need to annotate the field by checking if it has the decorator attribute set on the field. - f = getattr(entity, get_model_attr(field_name), None) - if f is not None: - for decorator, decorator_resolver in DECORATORS.items(): - decorator_value = getattr(f, decorator, None) - if decorator_value: - str_field += f" {decorator_resolver(schema, decorator_value)}" - str_fields.append(str_field) - str_fields_annotated = "\n".join(str_fields) - # Replace the original field declaration by the annotated one - if isinstance(entity_type, GraphQLObjectType) or isinstance( - entity_type, GraphQLInterfaceType - ): - str_fields_original = field_to_string(entity_type) - else: - str_fields_original = "" - pattern = re.compile( - r"(type\s%s\s[^\{]*)\{\s*%s\s*\}" - % (entity_name, re.escape(str_fields_original)) - ) - string_schema = pattern.sub(r"\g<1> {\n%s\n}" % str_fields_annotated, string_schema) - return string_schema - - -def get_sdl(schema: Schema) -> str: +def get_sdl(schema) -> str: """ Add all needed decorators to the string representation of the schema. """ string_schema = str(schema) - - regex = r"schema \{(\w|\!|\s|\:)*\}" - pattern = re.compile(regex) - string_schema = pattern.sub(" ", string_schema) - - # Get various objects that need to be amended - extended_types = get_extended_types(schema) - provides_parent_types = get_provides_parent_types(schema) - provides_fields = get_provides_fields(schema) - entities = get_entities(schema) - required_fields = get_required_fields(schema) - external_fields = get_external_fields(schema) - external_object_types = get_external_object_types(schema) - override_fields = get_override_fields(schema) - - schema_extensions = [] - - if schema.federation_version >= 2: - shareable_types = get_shareable_types(schema) - inaccessible_types = get_inaccessible_types(schema) - shareable_fields = get_shareable_fields(schema) - tagged_fields = get_tagged_fields(schema) - inaccessible_fields = get_inaccessible_fields(schema) - - federation_spec_import = [] - - if extended_types or external_object_types: - federation_spec_import.append('"@extends"') - if external_fields: - federation_spec_import.append('"@external"') - if entities: - federation_spec_import.append('"@key"') - if override_fields: - federation_spec_import.append('"@override"') - if provides_parent_types or provides_fields: - federation_spec_import.append('"@provides"') - if required_fields: - federation_spec_import.append('"@requires"') - if inaccessible_types or inaccessible_fields: - federation_spec_import.append('"@inaccessible"') - if shareable_types or shareable_fields: - federation_spec_import.append('"@shareable"') - if tagged_fields: - federation_spec_import.append('"@tag"') - - if schema.federation_version >= 2.1 and hasattr(schema, "directives"): - preserved_directives = [ - directive for directive in schema.directives if is_composable(directive) - ] - if preserved_directives: - federation_spec_import.append('"@composeDirective"') - schema_extensions.append( - compose_directive_schema_extensions(preserved_directives) - ) - - schema_import = ", ".join(federation_spec_import) - schema_extensions = [ - f'@link(url: "https://specs.apollo.dev/federation/v{schema.federation_version}", import: [{schema_import}])' - ] + schema_extensions - - # Add fields directives (@external, @provides, @requires, @shareable, @inaccessible) - entities_ = ( - set(provides_parent_types.values()) - | set(extended_types.values()) - | set(entities.values()) - | set(required_fields.values()) - | set(provides_fields.values()) - ) - - if schema.federation_version >= 2: - entities_ = ( - entities_ - | set(shareable_types.values()) - | set(inaccessible_types.values()) - | set(inaccessible_fields.values()) - | set(shareable_fields.values()) - | set(tagged_fields.values()) - ) - for entity in entities_: - string_schema = add_entity_fields_decorators(entity, schema, string_schema) - - # Prepend `extend` keyword to the type definition of extended types - # noinspection DuplicatedCode - for entity_name, entity in extended_types.items(): - type_def = re.compile(rf"type {entity_name} ([^{{]*)") - repl_str = rf"extend type {entity_name} \1" - string_schema = type_def.sub(repl_str, string_schema) - - # Add entity keys declarations - get_field_name = type_attribute_to_field_name(schema) - for entity_name, entity in entities.items(): - type_def_re = rf"(type {entity_name} [^\{{]*)" + " " - - # resolvable argument of @key directive is true by default. If false, we add 'resolvable: false' to sdl. - if ( - schema.federation_version >= 2 - and hasattr(entity, "_resolvable") - and not entity._resolvable - ): - type_annotation = ( - ( - " ".join( - [ - f'@key(fields: "{get_field_name(key)}"' - for key in entity._keys - ] - ) - ) - + f", resolvable: {str(entity._resolvable).lower()})" - + " " - ) - else: - type_annotation = ( - " ".join( - [f'@key(fields: "{get_field_name(key)}")' for key in entity._keys] - ) - + " " - ) - repl_str = rf"\1{type_annotation}" - pattern = re.compile(type_def_re) - string_schema = pattern.sub(repl_str, string_schema) - - if schema.federation_version >= 2: - # Add `@external` keyword to the type definition of external object types - for object_type_name, _ in external_object_types.items(): - type_def = re.compile(rf"type {object_type_name} ([^{{]*)") - repl_str = rf"type {object_type_name} @external \1" - string_schema = type_def.sub(repl_str, string_schema) - - for type_name, type in shareable_types.items(): - # noinspection PyProtectedMember - if isinstance(type._meta, UnionOptions): - type_def_re = rf"(union {type_name})" - else: - type_def_re = rf"(type {type_name} [^\{{]*)" + " " - type_annotation = " @shareable" - repl_str = rf"\1{type_annotation} " - pattern = re.compile(type_def_re) - string_schema = pattern.sub(repl_str, string_schema) - - for type_name, type in inaccessible_types.items(): - # noinspection PyProtectedMember - if isinstance(type._meta, InterfaceOptions): - type_def_re = rf"(interface {type_name}[^\{{]*)" - elif isinstance(type._meta, UnionOptions): - type_def_re = rf"(union {type_name})" - else: - type_def_re = rf"(type {type_name} [^\{{]*)" + " " - type_annotation = " @inaccessible" - repl_str = rf"\1{type_annotation} " - pattern = re.compile(type_def_re) - string_schema = pattern.sub(repl_str, string_schema) - - if schema_extensions: - string_schema = ( - "extend schema\n " + "\n".join(schema_extensions) + "\n" + string_schema - ) - - return string_schema + return string_schema.strip() def get_service_query(schema: Schema): + """ + Gets the Service Query for federation + """ sdl_str = get_sdl(schema) class _Service(ObjectType): sdl = String() - def resolve_sdl(parent, _): + def resolve_sdl(self, _) -> str: # noqa return sdl_str class ServiceQuery(ObjectType): _service = Field(_Service, name="_service", required=True) - def resolve__service(parent, info): + def resolve__service(self, info) -> _Service: # noqa return _Service() return ServiceQuery diff --git a/graphene_federation/shareable.py b/graphene_federation/shareable.py deleted file mode 100644 index 29634f6..0000000 --- a/graphene_federation/shareable.py +++ /dev/null @@ -1,67 +0,0 @@ -from typing import Any, Dict, Optional - -from graphene import Schema -from graphene.types.interface import InterfaceOptions - -from graphene_federation.utils import get_attributed_fields - - -def get_shareable_types(schema: Schema) -> Dict[str, Any]: - """ - Find all the extended types from the schema. - They can be easily distinguished from the other type as - the `@shareable` decorator adds a `_shareable` attribute to them. - """ - shareable_types = {} - for type_name, type_ in schema.graphql_schema.type_map.items(): - if not hasattr(type_, "graphene_type"): - continue - if type_name == "PageInfo" or getattr(type_.graphene_type, "_shareable", False): - shareable_types[type_name] = type_.graphene_type - return shareable_types - - -def shareable(field: Optional[Any] = None) -> Any: - """ - Decorator to use to shareable a given type. - """ - - # noinspection PyProtectedMember,PyPep8Naming - def decorator(type_): - assert not hasattr( - type_, "_keys" - ), "Can't extend type which is already extended or has @key" - assert not hasattr( - type_, "_keys" - ), "Can't extend type which is already extended or has @key" - # Check the provided fields actually exist on the Type. - assert getattr(type_._meta, "description", None) is None, ( - f'Type "{type_.__name__}" has a non empty description and it is also marked with extend.' - "\nThey are mutually exclusive." - "\nSee https://github.com/graphql/graphql-js/issues/2385#issuecomment-577997521" - ) - # Set a `_shareable` attribute to be able to distinguish it from the other entities - setattr(type_, "_shareable", True) - return type_ - - if field: - assert not isinstance(field._meta, InterfaceOptions), ( - "The @Shareable directive is about indicating when an object field " - "can be resolved by multiple subgraphs. As interface fields are not " - "directly resolved (their implementation is), @Shareable is not " - "meaningful on an interface field and is not allowed (at least since " - "federation 2.2; earlier versions of federation 2 mistakenly ignored " - "@Shareable on interface fields). " - ) - field._shareable = True - return field - return decorator - - -def get_shareable_fields(schema: Schema) -> dict: - """ - Find all the extended types from the schema. - They can be easily distinguished from the other type as - the `@shareable` decorator adds a `_shareable` attribute to them. - """ - return get_attributed_fields(attribute="_shareable", schema=schema) diff --git a/graphene_federation/tag.py b/graphene_federation/tag.py deleted file mode 100644 index 05f0fb3..0000000 --- a/graphene_federation/tag.py +++ /dev/null @@ -1,20 +0,0 @@ -from graphene import Schema - -from graphene_federation.utils import get_attributed_fields - - -def tag(field, name: str): - """ - Decorator to use to override a given type. - """ - field._tag = name - return field - - -def get_tagged_fields(schema: Schema) -> dict: - """ - Find all the extended types from the schema. - They can be easily distinguished from the other type as - the `@external` decorator adds a `_external` attribute to them. - """ - return get_attributed_fields(attribute="_tag", schema=schema) diff --git a/graphene_federation/tests/test_annotation_corner_cases.py b/graphene_federation/tests/test_annotation_corner_cases.py deleted file mode 100644 index 72aeea0..0000000 --- a/graphene_federation/tests/test_annotation_corner_cases.py +++ /dev/null @@ -1,391 +0,0 @@ -from textwrap import dedent - -from graphql import graphql_sync - -from graphene import ObjectType, ID, String, Field - -from graphene_federation import external, requires -from graphene_federation.entity import key -from graphene_federation.extend import extend -from graphene_federation.main import build_schema -from graphene_federation.utils import clean_schema - - -def test_similar_field_name(): - """ - Test annotation with fields that have similar names. - """ - - @extend("id") - class ChatUser(ObjectType): - uid = ID() - identified = ID() - id = external(ID()) - i_d = ID() - ID = ID() - - class ChatMessage(ObjectType): - id = ID(required=True) - user = Field(ChatUser) - - class ChatQuery(ObjectType): - message = Field(ChatMessage, id=ID(required=True)) - - chat_schema = build_schema(query=ChatQuery, enable_federation_2=True) - expected_result = dedent( - """ - schema { - query: ChatQuery - } - - type ChatQuery { - message(id: ID!): ChatMessage - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type ChatMessage { - id: ID! - user: ChatUser - } - - type ChatUser { - uid: ID - identified: ID - id: ID - iD: ID - ID: ID - } - - union _Entity = ChatUser - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(chat_schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(chat_schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key"]) - - type ChatQuery { - message(id: ID!): ChatMessage - } - type ChatMessage { - id: ID! - user: ChatUser - } - extend type ChatUser @key(fields: "id") { - uid: ID - identified: ID - id: ID @external - iD: ID - ID: ID - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_camel_case_field_name(): - """ - Test annotation with fields that have camel cases or snake case. - """ - - @extend("auto_camel") - class Camel(ObjectType): - auto_camel = external(String()) - forcedCamel = requires(String(), fields="auto_camel") - a_snake = String() - aCamel = String() - - class Query(ObjectType): - camel = Field(Camel) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - camel: Camel - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Camel { - autoCamel: String - forcedCamel: String - aSnake: String - aCamel: String - } - - union _Entity = Camel - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key", "@requires"]) - type Query { - camel: Camel - } - extend type Camel @key(fields: "autoCamel") { - autoCamel: String @external - forcedCamel: String @requires(fields: "autoCamel") - aSnake: String - aCamel: String - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_camel_case_field_name_without_auto_camelcase(): - """ - Test annotation with fields that have camel cases or snake case but with the auto_camelcase disabled. - """ - - @extend("auto_camel") - class Camel(ObjectType): - auto_camel = external(String()) - forcedCamel = requires(String(), fields="auto_camel") - a_snake = String() - aCamel = String() - - class Query(ObjectType): - camel = Field(Camel) - - schema = build_schema(query=Query, auto_camelcase=False, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - camel: Camel - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Camel { - auto_camel: String - forcedCamel: String - a_snake: String - aCamel: String - } - - union _Entity = Camel - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key", "@requires"]) - type Query { - camel: Camel - } - - extend type Camel @key(fields: "auto_camel") { - auto_camel: String @external - forcedCamel: String @requires(fields: "auto_camel") - a_snake: String - aCamel: String - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_annotated_field_also_used_in_filter(): - """ - Test that when a field also used in filter needs to get annotated, it really annotates only the field. - See issue https://github.com/preply/graphene-federation/issues/50 - """ - - @key("id") - class B(ObjectType): - id = ID() - - @extend("id") - class A(ObjectType): - id = external(ID()) - b = Field(B, id=ID()) - - class Query(ObjectType): - a = Field(A) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - a: A - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type A { - id: ID - b(id: ID): B - } - - type B { - id: ID - } - - union _Entity = A | B - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key"]) - type Query { - a: A - } - - extend type A @key(fields: "id") { - id: ID @external - b(id: ID): B - } - - type B @key(fields: "id") { - id: ID - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_annotate_object_with_meta_name(): - @key("id") - class B(ObjectType): - class Meta: - name = "Potato" - - id = ID() - - @extend("id") - class A(ObjectType): - class Meta: - name = "Banana" - - id = external(ID()) - b = Field(B, id=ID()) - - class Query(ObjectType): - a = Field(A) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - a: Banana - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Banana { - id: ID - b(id: ID): Potato - } - - type Potato { - id: ID - } - - union _Entity = Banana | Potato - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key"]) - type Query { - a: Banana - } - - extend type Banana @key(fields: "id") { - id: ID @external - b(id: ID): Potato - } - - type Potato @key(fields: "id") { - id: ID - } - """ - ) - # assert compare_schema(result.data["_service"]["sdl"].strip(), expected_result) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/tests/test_annotation_corner_cases_v1.py b/graphene_federation/tests/test_annotation_corner_cases_v1.py deleted file mode 100644 index 82ca7fc..0000000 --- a/graphene_federation/tests/test_annotation_corner_cases_v1.py +++ /dev/null @@ -1,388 +0,0 @@ -from textwrap import dedent - -from graphql import graphql_sync - -from graphene import ObjectType, ID, String, Field - -from graphene_federation.entity import key -from graphene_federation.extend import extend -from graphene_federation.external import external -from graphene_federation.requires import requires -from graphene_federation.main import build_schema -from graphene_federation.utils import clean_schema - - -def test_similar_field_name(): - """ - Test annotation with fields that have similar names. - """ - - @extend("id") - class ChatUser(ObjectType): - uid = ID() - identified = ID() - id = external(ID()) - i_d = ID() - ID = ID() - - class ChatMessage(ObjectType): - id = ID(required=True) - user = Field(ChatUser) - - class ChatQuery(ObjectType): - message = Field(ChatMessage, id=ID(required=True)) - - chat_schema = build_schema(query=ChatQuery) - expected_result = dedent( - """ - schema { - query: ChatQuery - } - - type ChatQuery { - message(id: ID!): ChatMessage - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type ChatMessage { - id: ID! - user: ChatUser - } - - type ChatUser { - uid: ID - identified: ID - id: ID - iD: ID - ID: ID - } - - union _Entity = ChatUser - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(chat_schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(chat_schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type ChatQuery { - message(id: ID!): ChatMessage - } - - type ChatMessage { - id: ID! - user: ChatUser - } - - extend type ChatUser @key(fields: "id") { - uid: ID - identified: ID - id: ID @external - iD: ID - ID: ID - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_camel_case_field_name(): - """ - Test annotation with fields that have camel cases or snake case. - """ - - @extend("auto_camel") - class Camel(ObjectType): - auto_camel = external(String()) - forcedCamel = requires(String(), fields="auto_camel") - a_snake = String() - aCamel = String() - - class Query(ObjectType): - camel = Field(Camel) - - schema = build_schema(query=Query) - expected_result = dedent( - """ - type Query { - camel: Camel - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Camel { - autoCamel: String - forcedCamel: String - aSnake: String - aCamel: String - } - - union _Entity = Camel - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - camel: Camel - } - - extend type Camel @key(fields: "autoCamel") { - autoCamel: String @external - forcedCamel: String @requires(fields: "autoCamel") - aSnake: String - aCamel: String - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_camel_case_field_name_without_auto_camelcase(): - """ - Test annotation with fields that have camel cases or snake case but with the auto_camelcase disabled. - """ - - @extend("auto_camel") - class Camel(ObjectType): - auto_camel = external(String()) - forcedCamel = requires(String(), fields="auto_camel") - a_snake = String() - aCamel = String() - - class Query(ObjectType): - camel = Field(Camel) - - schema = build_schema(query=Query, auto_camelcase=False) - expected_result = dedent( - """ - type Query { - camel: Camel - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Camel { - auto_camel: String - forcedCamel: String - a_snake: String - aCamel: String - } - - union _Entity = Camel - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - camel: Camel - } - - extend type Camel @key(fields: "auto_camel") { - auto_camel: String @external - forcedCamel: String @requires(fields: "auto_camel") - a_snake: String - aCamel: String - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_annotated_field_also_used_in_filter(): - """ - Test that when a field also used in filter needs to get annotated, it really annotates only the field. - See issue https://github.com/preply/graphene-federation/issues/50 - """ - - @key("id") - class B(ObjectType): - id = ID() - - @extend("id") - class A(ObjectType): - id = external(ID()) - b = Field(B, id=ID()) - - class Query(ObjectType): - a = Field(A) - - schema = build_schema(query=Query) - expected_result = dedent( - """ - type Query { - a: A - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type A { - id: ID - b(id: ID): B - } - - type B { - id: ID - } - - union _Entity = A | B - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - a: A - } - - extend type A @key(fields: "id") { - id: ID @external - b(id: ID): B - } - - type B @key(fields: "id") { - id: ID - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_annotate_object_with_meta_name(): - @key("id") - class B(ObjectType): - class Meta: - name = "Potato" - - id = ID() - - @extend("id") - class A(ObjectType): - class Meta: - name = "Banana" - - id = external(ID()) - b = Field(B, id=ID()) - - class Query(ObjectType): - a = Field(A) - - schema = build_schema(query=Query) - expected_result = dedent( - """ - type Query { - a: Banana - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Banana { - id: ID - b(id: ID): Potato - } - - type Potato { - id: ID - } - - union _Entity = Banana | Potato - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - a: Banana - } - - extend type Banana @key(fields: "id") { - id: ID @external - b(id: ID): Potato - } - - type Potato @key(fields: "id") { - id: ID - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/tests/test_custom_enum.py b/graphene_federation/tests/test_custom_enum.py deleted file mode 100644 index 933c559..0000000 --- a/graphene_federation/tests/test_custom_enum.py +++ /dev/null @@ -1,57 +0,0 @@ -from textwrap import dedent - -import graphene -from graphene import ObjectType -from graphql import graphql_sync - -from graphene_federation import build_schema, shareable, inaccessible -from graphene_federation.utils import clean_schema - - -def test_custom_enum(): - class Episode(graphene.Enum): - NEWHOPE = 4 - EMPIRE = 5 - JEDI = 6 - - @shareable - class TestCustomEnum(graphene.ObjectType): - test_shareable_scalar = shareable(Episode()) - test_inaccessible_scalar = inaccessible(Episode()) - - class Query(ObjectType): - test = Episode() - test2 = graphene.List(TestCustomEnum, required=True) - - schema = build_schema( - query=Query, enable_federation_2=True, types=(TestCustomEnum,) - ) - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@inaccessible", "@shareable"]) - type TestCustomEnum @shareable { - testShareableScalar: Episode @shareable - testInaccessibleScalar: Episode @inaccessible - } - - enum Episode { - NEWHOPE - EMPIRE - JEDI - } - - type Query { - test: Episode - test2: [TestCustomEnum]! - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/tests/test_entity.py b/graphene_federation/tests/test_entity.py deleted file mode 100644 index fab55d2..0000000 --- a/graphene_federation/tests/test_entity.py +++ /dev/null @@ -1 +0,0 @@ -# test resolve_entities method diff --git a/graphene_federation/tests/test_entity_v1.py b/graphene_federation/tests/test_entity_v1.py deleted file mode 100644 index fab55d2..0000000 --- a/graphene_federation/tests/test_entity_v1.py +++ /dev/null @@ -1 +0,0 @@ -# test resolve_entities method diff --git a/graphene_federation/tests/test_extend.py b/graphene_federation/tests/test_extend.py deleted file mode 100644 index b34c884..0000000 --- a/graphene_federation/tests/test_extend.py +++ /dev/null @@ -1,128 +0,0 @@ -from textwrap import dedent - -import pytest - -from graphene import ObjectType, ID, String, Field -from graphql import graphql_sync - -from graphene_federation import build_schema, external, shareable -from graphene_federation.utils import clean_schema -from graphene_federation.extend import extend - - -def test_extend_non_existing_field_failure(): - """ - Test that using the key decorator and providing a field that does not exist fails. - """ - with pytest.raises(AssertionError) as err: - - @extend("potato") - class A(ObjectType): - id = ID() - - assert 'Field "potato" does not exist on type "A"' == str(err.value) - - -def test_multiple_extend_failure(): - """ - Test that the extend decorator can't be used more than once on a type. - """ - with pytest.raises(AssertionError) as err: - - @extend("id") - @extend("potato") - class A(ObjectType): - id = ID() - potato = String() - - assert "Can't extend type which is already extended or has @key" == str(err.value) - - -def test_extend_with_description_failure(): - """ - Test that adding a description to an extended type raises an error. - """ - with pytest.raises(AssertionError) as err: - - @extend("id") - class A(ObjectType): - class Meta: - description = "This is an object from here." - - id = ID() - - assert ( - 'Type "A" has a non empty description and it is also marked with extend.\nThey are mutually exclusive.' - in str(err.value) - ) - - -def test_extend_with_compound_primary_keys(): - @shareable - class Organization(ObjectType): - id = ID() - - @extend(fields="id organization {id }") - class User(ObjectType): - id = external(ID()) - organization = Field(Organization) - - class Query(ObjectType): - user = Field(User) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - user: User - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type User { - id: ID - organization: Organization - } - - type Organization { - id: ID - } - - union _Entity = User - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key", "@shareable"]) - type Query { - user: User - } - - extend type User @key(fields: "id organization {id }") { - id: ID @external - organization: Organization - } - - type Organization @shareable { - id: ID - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/tests/test_extend_v1.py b/graphene_federation/tests/test_extend_v1.py deleted file mode 100644 index 9999b0a..0000000 --- a/graphene_federation/tests/test_extend_v1.py +++ /dev/null @@ -1,52 +0,0 @@ -import pytest - -from graphene import ObjectType, ID, String - -from ..extend import extend - - -def test_extend_non_existing_field_failure(): - """ - Test that using the key decorator and providing a field that does not exist fails. - """ - with pytest.raises(AssertionError) as err: - - @extend("potato") - class A(ObjectType): - id = ID() - - assert 'Field "potato" does not exist on type "A"' == str(err.value) - - -def test_multiple_extend_failure(): - """ - Test that the extend decorator can't be used more than once on a type. - """ - with pytest.raises(AssertionError) as err: - - @extend("id") - @extend("potato") - class A(ObjectType): - id = ID() - potato = String() - - assert "Can't extend type which is already extended or has @key" == str(err.value) - - -def test_extend_with_description_failure(): - """ - Test that adding a description to an extended type raises an error. - """ - with pytest.raises(AssertionError) as err: - - @extend("id") - class A(ObjectType): - class Meta: - description = "This is an object from here." - - id = ID() - - assert ( - 'Type "A" has a non empty description and it is also marked with extend.\nThey are mutually exclusive.' - in str(err.value) - ) diff --git a/graphene_federation/tests/test_inaccessible.py b/graphene_federation/tests/test_inaccessible.py deleted file mode 100644 index 171c94b..0000000 --- a/graphene_federation/tests/test_inaccessible.py +++ /dev/null @@ -1,126 +0,0 @@ -from textwrap import dedent - -import graphene -from graphene import ObjectType -from graphql import graphql_sync - -from graphene_federation import inaccessible, build_schema -from graphene_federation.utils import clean_schema - - -def test_inaccessible_interface(): - @inaccessible - class ReviewInterface(graphene.Interface): - interfaced_body = graphene.String(required=True) - - @inaccessible - class Review(graphene.ObjectType): - class Meta: - interfaces = (ReviewInterface,) - - id = inaccessible(graphene.Int(required=True)) - body = graphene.String(required=True) - - class Query(ObjectType): - in_stock_count = graphene.Int(required=True) - - build_schema(query=Query, enable_federation_2=True, types=(ReviewInterface, Review)) - - -def test_inaccessible(): - @inaccessible - class Position(graphene.ObjectType): - x = graphene.Int(required=True) - y = inaccessible(graphene.Int(required=True)) - - class Query(ObjectType): - in_stock_count = graphene.Int(required=True) - - schema = build_schema(query=Query, enable_federation_2=True, types=(Position,)) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@inaccessible"]) - type Position @inaccessible { - x: Int! - y: Int! @inaccessible - } - - type Query { - inStockCount: Int! - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_inaccessible_union(): - @inaccessible - class Human(graphene.ObjectType): - name = graphene.String() - born_in = graphene.String() - - @inaccessible - class Droid(graphene.ObjectType): - name = inaccessible(graphene.String()) - primary_function = graphene.String() - - @inaccessible - class Starship(graphene.ObjectType): - name = graphene.String() - length = inaccessible(graphene.Int()) - - @inaccessible - class SearchResult(graphene.Union): - class Meta: - types = (Human, Droid, Starship) - - class Query(ObjectType): - in_stock_count = graphene.Int(required=True) - - schema = build_schema(query=Query, enable_federation_2=True, types=(SearchResult,)) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_schema = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@inaccessible"]) - union SearchResult @inaccessible = Human | Droid | Starship - - type Human @inaccessible { - name: String - bornIn: String - } - - type Droid @inaccessible { - name: String @inaccessible - primaryFunction: String - } - - type Starship @inaccessible { - name: String - length: Int @inaccessible - } - - type Query { - inStockCount: Int! - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_schema) diff --git a/graphene_federation/tests/test_key.py b/graphene_federation/tests/test_key.py deleted file mode 100644 index 5f09c95..0000000 --- a/graphene_federation/tests/test_key.py +++ /dev/null @@ -1,306 +0,0 @@ -import pytest -from textwrap import dedent - -from graphql import graphql_sync - -from graphene import ObjectType, ID, String, Field - -from graphene_federation.entity import key -from graphene_federation.main import build_schema -from graphene_federation.utils import clean_schema - - -def test_multiple_keys(): - @key("identifier") - @key("email") - class User(ObjectType): - identifier = ID() - email = String() - - class Query(ObjectType): - user = Field(User) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - user: User - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type User { - identifier: ID - email: String - } - - union _Entity = User - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) - type Query { - user: User - } - - type User @key(fields: "email") @key(fields: "identifier") { - identifier: ID - email: String - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_key_non_existing_field_failure(): - """ - Test that using the key decorator and providing a field that does not exist fails. - """ - with pytest.raises(AssertionError) as err: - - @key("potato") - class A(ObjectType): - id = ID() - - assert 'Field "potato" does not exist on type "A"' == str(err.value) - - -def test_compound_primary_key(): - class Organization(ObjectType): - registration_number = ID() - - @key("id organization { registration_number }") - class User(ObjectType): - id = ID() - organization = Field(Organization) - - class Query(ObjectType): - user = Field(User) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - user: User - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type User { - id: ID - organization: Organization - } - - type Organization { - registrationNumber: ID - } - - union _Entity = User - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) - type Query { - user: User - } - - type User @key(fields: "id organization { registrationNumber }") { - id: ID - organization: Organization - } - - type Organization { - registrationNumber: ID - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_compound_primary_key_with_depth(): - class BusinessUnit(ObjectType): - id = ID() - name = String() - - class Organization(ObjectType): - registration_number = ID() - business_unit = Field(BusinessUnit) - - @key("id organization { business_unit {id name}}") - class User(ObjectType): - id = ID() - organization = Field(Organization) - - class Query(ObjectType): - user = Field(User) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - user: User - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type User { - id: ID - organization: Organization - } - - type Organization { - registrationNumber: ID - businessUnit: BusinessUnit - } - - type BusinessUnit { - id: ID - name: String - } - - union _Entity = User - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) - type Query { - user: User - } - - type User @key(fields: "id organization { businessUnit {id name}}") { - id: ID - organization: Organization - } - - type Organization { - registrationNumber: ID - businessUnit: BusinessUnit - } - - type BusinessUnit { - id: ID - name: String - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_invalid_compound_primary_key_failures(): - class BusinessUnit(ObjectType): - id = ID() - name = String() - - class Organization(ObjectType): - registration_number = ID() - bu = Field(BusinessUnit) - - @key("id name organization { registration_number }") - class User(ObjectType): - id = ID() - organization = Field(Organization) - - class Query(ObjectType): - user = Field(User) - - with pytest.raises(AssertionError) as err: - # Field name absent on User ObjectType - build_schema(query=Query, enable_federation_2=True) - - assert 'Invalid compound key definition for type "User"' == str(err.value) - - @key("id organization { name }") - class User(ObjectType): - id = ID() - organization = Field(Organization) - - class Query(ObjectType): - user = Field(User) - - with pytest.raises(AssertionError) as err: - # Presence of invalid field in organization field key - build_schema(query=Query, enable_federation_2=True) - - assert 'Invalid compound key definition for type "User"' == str(err.value) - - @key("id organization { bu }") - class User(ObjectType): - id = ID() - organization = Field(Organization) - - class Query(ObjectType): - user = Field(User) - - with pytest.raises(AssertionError) as err: - # Presence of BusinessUnit in the key without subselection - build_schema(query=Query, enable_federation_2=True) - - assert 'Invalid compound key definition for type "User"' == str(err.value) - - @key("id organization { bu {name { field }} }") - class User(ObjectType): - id = ID() - organization = Field(Organization) - - class Query(ObjectType): - user = Field(User) - - with pytest.raises(AssertionError) as err: - # Presence of subselection for the scalar 'name' field - build_schema(query=Query, enable_federation_2=True) - - assert 'Invalid compound key definition for type "User"' == str(err.value) diff --git a/graphene_federation/tests/test_key_v1.py b/graphene_federation/tests/test_key_v1.py deleted file mode 100644 index a25f81b..0000000 --- a/graphene_federation/tests/test_key_v1.py +++ /dev/null @@ -1,83 +0,0 @@ -from textwrap import dedent - -import pytest - -from graphql import graphql_sync - -from graphene import ObjectType, ID, String, Field - -from graphene_federation.entity import key -from graphene_federation.main import build_schema -from graphene_federation.utils import clean_schema - - -def test_multiple_keys(): - @key("identifier") - @key("email") - class User(ObjectType): - identifier = ID() - email = String() - - class Query(ObjectType): - user = Field(User) - - schema = build_schema(query=Query) - expected_result = dedent( - """ - type Query { - user: User - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type User { - identifier: ID - email: String - } - - union _Entity = User - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - user: User - } - - type User @key(fields: "email") @key(fields: "identifier") { - identifier: ID - email: String - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_key_non_existing_field_failure(): - """ - Test that using the key decorator and providing a field that does not exist fails. - """ - with pytest.raises(AssertionError) as err: - - @key("potato") - class A(ObjectType): - id = ID() - - assert 'Field "potato" does not exist on type "A"' == str(err.value) diff --git a/graphene_federation/tests/test_provides.py b/graphene_federation/tests/test_provides.py deleted file mode 100644 index 021f6c3..0000000 --- a/graphene_federation/tests/test_provides.py +++ /dev/null @@ -1,256 +0,0 @@ -from textwrap import dedent - -from graphql import graphql_sync - -from graphene import Field, Int, ObjectType, String - -from graphene_federation import external -from graphene_federation.provides import provides -from graphene_federation.main import build_schema -from graphene_federation.extend import extend -from graphene_federation.utils import clean_schema - - -def test_provides(): - """ - https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced - """ - - @extend("sku") - class Product(ObjectType): - sku = external(String(required=True)) - name = external(String()) - weight = external(Int()) - - @provides - class InStockCount(ObjectType): - product = provides(Field(Product, required=True), fields="name") - quantity = Int(required=True) - - class Query(ObjectType): - in_stock_count = Field(InStockCount) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - inStockCount: InStockCount - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type InStockCount { - product: Product! - quantity: Int! - } - - type Product { - sku: String! - name: String - weight: Int - } - - union _Entity = Product - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key", "@provides"]) - type Query { - inStockCount: InStockCount - } - - type InStockCount { - product: Product! @provides(fields: "name") - quantity: Int! - } - - extend type Product @key(fields: "sku") { - sku: String! @external - name: String @external - weight: Int @external - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_provides_multiple_fields(): - """ - https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced - """ - - @extend("sku") - class Product(ObjectType): - sku = external(String(required=True)) - name = external(String()) - weight = external(Int()) - - @provides - class InStockCount(ObjectType): - product = provides(Field(Product, required=True), fields="name weight") - quantity = Int(required=True) - - class Query(ObjectType): - in_stock_count = Field(InStockCount) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - inStockCount: InStockCount - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type InStockCount { - product: Product! - quantity: Int! - } - - type Product { - sku: String! - name: String - weight: Int - } - - union _Entity = Product - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key", "@provides"]) - type Query { - inStockCount: InStockCount - } - - type InStockCount { - product: Product! @provides(fields: "name weight") - quantity: Int! - } - - extend type Product @key(fields: "sku") { - sku: String! @external - name: String @external - weight: Int @external - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_provides_multiple_fields_as_list(): - """ - https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced - """ - - @extend("sku") - class Product(ObjectType): - sku = external(String(required=True)) - name = external(String()) - weight = external(Int()) - - @provides - class InStockCount(ObjectType): - product = provides(Field(Product, required=True), fields=["name", "weight"]) - quantity = Int(required=True) - - class Query(ObjectType): - in_stock_count = Field(InStockCount) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - inStockCount: InStockCount - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type InStockCount { - product: Product! - quantity: Int! - } - - type Product { - sku: String! - name: String - weight: Int - } - - union _Entity = Product - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key", "@provides"]) - type Query { - inStockCount: InStockCount - } - - type InStockCount { - product: Product! @provides(fields: "name weight") - quantity: Int! - } - - extend type Product @key(fields: "sku") { - sku: String! @external - name: String @external - weight: Int @external - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/tests/test_provides_v1.py b/graphene_federation/tests/test_provides_v1.py deleted file mode 100644 index c59649b..0000000 --- a/graphene_federation/tests/test_provides_v1.py +++ /dev/null @@ -1,251 +0,0 @@ -from textwrap import dedent - -from graphql import graphql_sync - -from graphene import Field, Int, ObjectType, String - -from graphene_federation.provides import provides -from graphene_federation.main import build_schema -from graphene_federation.extend import extend -from graphene_federation.external import external -from graphene_federation.utils import clean_schema - - -def test_provides(): - """ - https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced - """ - - @extend("sku") - class Product(ObjectType): - sku = external(String(required=True)) - name = external(String()) - weight = external(Int()) - - @provides - class InStockCount(ObjectType): - product = provides(Field(Product, required=True), fields="name") - quantity = Int(required=True) - - class Query(ObjectType): - in_stock_count = Field(InStockCount) - - schema = build_schema(query=Query) - expected_result = dedent( - """ - type Query { - inStockCount: InStockCount - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type InStockCount { - product: Product! - quantity: Int! - } - - type Product { - sku: String! - name: String - weight: Int - } - - union _Entity = Product - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - inStockCount: InStockCount - } - - type InStockCount { - product: Product! @provides(fields: "name") - quantity: Int! - } - - extend type Product @key(fields: "sku") { - sku: String! @external - name: String @external - weight: Int @external - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_provides_multiple_fields(): - """ - https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced - """ - - @extend("sku") - class Product(ObjectType): - sku = external(String(required=True)) - name = external(String()) - weight = external(Int()) - - @provides - class InStockCount(ObjectType): - product = provides(Field(Product, required=True), fields="name weight") - quantity = Int(required=True) - - class Query(ObjectType): - in_stock_count = Field(InStockCount) - - schema = build_schema(query=Query) - expected_result = dedent( - """ - type Query { - inStockCount: InStockCount - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type InStockCount { - product: Product! - quantity: Int! - } - - type Product { - sku: String! - name: String - weight: Int - } - - union _Entity = Product - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - inStockCount: InStockCount - } - - type InStockCount { - product: Product! @provides(fields: "name weight") - quantity: Int! - } - - extend type Product @key(fields: "sku") { - sku: String! @external - name: String @external - weight: Int @external - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_provides_multiple_fields_as_list(): - """ - https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced - """ - - @extend("sku") - class Product(ObjectType): - sku = external(String(required=True)) - name = external(String()) - weight = external(Int()) - - @provides - class InStockCount(ObjectType): - product = provides(Field(Product, required=True), fields=["name", "weight"]) - quantity = Int(required=True) - - class Query(ObjectType): - in_stock_count = Field(InStockCount) - - schema = build_schema(query=Query) - expected_result = dedent( - """ - type Query { - inStockCount: InStockCount - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type InStockCount { - product: Product! - quantity: Int! - } - - type Product { - sku: String! - name: String - weight: Int - } - - union _Entity = Product - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - inStockCount: InStockCount - } - - type InStockCount { - product: Product! @provides(fields: "name weight") - quantity: Int! - } - - extend type Product @key(fields: "sku") { - sku: String! @external - name: String @external - weight: Int @external - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/tests/test_requires.py b/graphene_federation/tests/test_requires.py deleted file mode 100644 index 469c95d..0000000 --- a/graphene_federation/tests/test_requires.py +++ /dev/null @@ -1,230 +0,0 @@ -from textwrap import dedent - -import pytest - -from graphql import graphql_sync - -from graphene import Field, ID, Int, ObjectType, String - -from graphene_federation import external, requires -from graphene_federation.extend import extend -from graphene_federation.main import build_schema -from graphene_federation.utils import clean_schema - - -def test_chain_requires_failure(): - """ - Check that we can't nest call the requires method on a field. - """ - with pytest.raises(AssertionError) as err: - - @extend("id") - class A(ObjectType): - id = external(ID()) - something = requires(requires(String(), fields="id"), fields="id") - - assert "Can't chain `requires()` method calls on one field." == str(err.value) - - -def test_requires_multiple_fields(): - """ - Check that requires can take more than one field as input. - """ - - @extend("sku") - class Product(ObjectType): - sku = external(ID()) - size = external(Int()) - weight = external(Int()) - shipping_estimate = requires(String(), fields="size weight") - - class Query(ObjectType): - product = Field(Product) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - product: Product - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Product { - sku: ID - size: Int - weight: Int - shippingEstimate: String - } - - union _Entity = Product - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key", "@requires"]) - type Query { - product: Product - } - - extend type Product @key(fields: "sku") { - sku: ID @external - size: Int @external - weight: Int @external - shippingEstimate: String @requires(fields: "size weight") - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_requires_multiple_fields_as_list(): - """ - Check that requires can take more than one field as input. - """ - - @extend("sku") - class Product(ObjectType): - sku = external(ID()) - size = external(Int()) - weight = external(Int()) - shipping_estimate = requires(String(), fields=["size", "weight"]) - - class Query(ObjectType): - product = Field(Product) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - product: Product - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Product { - sku: ID - size: Int - weight: Int - shippingEstimate: String - } - - union _Entity = Product - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key", "@requires"]) - type Query { - product: Product - } - - extend type Product @key(fields: "sku") { - sku: ID @external - size: Int @external - weight: Int @external - shippingEstimate: String @requires(fields: "size weight") - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_requires_with_input(): - """ - Test checking that the issue https://github.com/preply/graphene-federation/pull/47 is resolved. - """ - - @extend("id") - class Acme(ObjectType): - id = external(ID(required=True)) - age = external(Int()) - foo = requires(Field(String, someInput=String()), fields="age") - - class Query(ObjectType): - acme = Field(Acme) - - schema = build_schema(query=Query, enable_federation_2=True) - expected_result = dedent( - """ - type Query { - acme: Acme - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Acme { - id: ID! - age: Int - foo(someInput: String): String - } - - union _Entity = Acme - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key", "@requires"]) - type Query { - acme: Acme - } - - extend type Acme @key(fields: "id") { - id: ID! @external - age: Int @external - foo(someInput: String): String @requires(fields: "age") - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/tests/test_requires_v1.py b/graphene_federation/tests/test_requires_v1.py deleted file mode 100644 index 3505473..0000000 --- a/graphene_federation/tests/test_requires_v1.py +++ /dev/null @@ -1,228 +0,0 @@ -from textwrap import dedent - -import pytest - -from graphql import graphql_sync - -from graphene import Field, ID, Int, ObjectType, String - -from graphene_federation.extend import extend -from graphene_federation.external import external -from graphene_federation.requires import requires -from graphene_federation.main import build_schema -from graphene_federation.utils import clean_schema - - -def test_chain_requires_failure(): - """ - Check that we can't nest call the requires method on a field. - """ - with pytest.raises(AssertionError) as err: - - @extend("id") - class A(ObjectType): - id = external(ID()) - something = requires(requires(String(), fields="id"), fields="id") - - assert "Can't chain `requires()` method calls on one field." == str(err.value) - - -def test_requires_multiple_fields(): - """ - Check that requires can take more than one field as input. - """ - - @extend("sku") - class Product(ObjectType): - sku = external(ID()) - size = external(Int()) - weight = external(Int()) - shipping_estimate = requires(String(), fields="size weight") - - class Query(ObjectType): - product = Field(Product) - - schema = build_schema(query=Query) - expected_result = dedent( - """ - type Query { - product: Product - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Product { - sku: ID - size: Int - weight: Int - shippingEstimate: String - } - - union _Entity = Product - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - product: Product - } - - extend type Product @key(fields: "sku") { - sku: ID @external - size: Int @external - weight: Int @external - shippingEstimate: String @requires(fields: "size weight") - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_requires_multiple_fields_as_list(): - """ - Check that requires can take more than one field as input. - """ - - @extend("sku") - class Product(ObjectType): - sku = external(ID()) - size = external(Int()) - weight = external(Int()) - shipping_estimate = requires(String(), fields=["size", "weight"]) - - class Query(ObjectType): - product = Field(Product) - - schema = build_schema(query=Query) - expected_result = dedent( - """ - type Query { - product: Product - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Product { - sku: ID - size: Int - weight: Int - shippingEstimate: String - } - - union _Entity = Product - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - product: Product - } - - extend type Product @key(fields: "sku") { - sku: ID @external - size: Int @external - weight: Int @external - shippingEstimate: String @requires(fields: "size weight") - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_requires_with_input(): - """ - Test checking that the issue https://github.com/preply/graphene-federation/pull/47 is resolved. - """ - - @extend("id") - class Acme(ObjectType): - id = external(ID(required=True)) - age = external(Int()) - foo = requires(Field(String, someInput=String()), fields="age") - - class Query(ObjectType): - acme = Field(Acme) - - schema = build_schema(query=Query) - expected_result = dedent( - """ - type Query { - acme: Acme - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type Acme { - id: ID! - age: Int - foo(someInput: String): String - } - - union _Entity = Acme - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(schema) == clean_schema(expected_result) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type Query { - acme: Acme - } - - extend type Acme @key(fields: "id") { - id: ID! @external - age: Int @external - foo(someInput: String): String @requires(fields: "age") - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/tests/test_scalar.py b/graphene_federation/tests/test_scalar.py deleted file mode 100644 index e80a2d0..0000000 --- a/graphene_federation/tests/test_scalar.py +++ /dev/null @@ -1,61 +0,0 @@ -from textwrap import dedent -from typing import Any - -import graphene -from graphene import Scalar, String, ObjectType -from graphql import graphql_sync - -from graphene_federation import build_schema, shareable, inaccessible -from graphene_federation.utils import clean_schema - - -def test_custom_scalar(): - class AddressScalar(Scalar): - base = String - - @staticmethod - def coerce_address(value: Any): - ... - - serialize = coerce_address - parse_value = coerce_address - - @staticmethod - def parse_literal(ast): - ... - - @shareable - class TestScalar(graphene.ObjectType): - test_shareable_scalar = shareable(String(x=AddressScalar())) - test_inaccessible_scalar = inaccessible(String(x=AddressScalar())) - - class Query(ObjectType): - test = String(x=AddressScalar()) - test2 = graphene.List(AddressScalar, required=True) - - schema = build_schema(query=Query, enable_federation_2=True, types=(TestScalar,)) - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@inaccessible", "@shareable"]) - type TestScalar @shareable { - testShareableScalar(x: AddressScalar): String @shareable - testInaccessibleScalar(x: AddressScalar): String @inaccessible - } - - scalar AddressScalar - - type Query { - test(x: AddressScalar): String - test2: [AddressScalar]! - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/tests/test_schema_annotation.py b/graphene_federation/tests/test_schema_annotation.py deleted file mode 100644 index 7de4fe4..0000000 --- a/graphene_federation/tests/test_schema_annotation.py +++ /dev/null @@ -1,243 +0,0 @@ -from textwrap import dedent - -from graphql import graphql_sync - -from graphene import ObjectType, ID, String, NonNull, Field - -from graphene_federation import external -from graphene_federation.entity import key -from graphene_federation.extend import extend -from graphene_federation.main import build_schema -from graphene_federation.utils import clean_schema - -# ------------------------ -# User service -# ------------------------ -users = [ - {"user_id": "1", "name": "Jane", "email": "jane@mail.com"}, - {"user_id": "2", "name": "Jack", "email": "jack@mail.com"}, - {"user_id": "3", "name": "Mary", "email": "mary@mail.com"}, -] - - -@key("user_id") -@key("email") -class User(ObjectType): - user_id = ID(required=True) - email = String(required=True) - name = String() - - def __resolve_reference(self, info, *args, **kwargs): - if self.id: - user = next(filter(lambda x: x["id"] == self.id, users)) - elif self.email: - user = next(filter(lambda x: x["email"] == self.email, users)) - return User(**user) - - -class UserQuery(ObjectType): - user = Field(User, user_id=ID(required=True)) - - def resolve_user(self, info, user_id, *args, **kwargs): - return User(**next(filter(lambda x: x["user_id"] == user_id, users))) - - -user_schema = build_schema(query=UserQuery, enable_federation_2=True) - -# ------------------------ -# Chat service -# ------------------------ -chat_messages = [ - {"id": "1", "user_id": "1", "text": "Hi"}, - {"id": "2", "user_id": "1", "text": "How is the weather?"}, - {"id": "3", "user_id": "2", "text": "Who are you"}, - {"id": "4", "user_id": "3", "text": "Don't be rude Jack"}, - {"id": "5", "user_id": "3", "text": "Hi Jane"}, - {"id": "6", "user_id": "2", "text": "Sorry but weather sucks so I am upset"}, -] - - -@extend("user_id") -class ChatUser(ObjectType): - user_id = external(ID(required=True)) - - -class ChatMessage(ObjectType): - id = ID(required=True) - text = String() - user_id = ID() - user = NonNull(ChatUser) - - def resolve_user(self, info, *args, **kwargs): - return ChatUser(user_id=self.user_id) - - -class ChatQuery(ObjectType): - message = Field(ChatMessage, id=ID(required=True)) - - def resolve_message(self, info, id, *args, **kwargs): - return ChatMessage(**next(filter(lambda x: x["id"] == id, chat_messages))) - - -chat_schema = build_schema(query=ChatQuery, enable_federation_2=True) - - -# ------------------------ -# Tests -# ------------------------ - - -def test_user_schema(): - """ - Check that the user schema has been annotated correctly - and that a request to retrieve a user works. - """ - expected_result = dedent( - """ - schema { - query: UserQuery - } - - type UserQuery { - user(userId: ID!): User - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type User { - userId: ID! - email: String! - name: String - } - - union _Entity = User - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(user_schema) == clean_schema(expected_result) - - query = """ - query { - user(userId: "2") { - name - } - } - """ - result = graphql_sync(user_schema.graphql_schema, query) - assert not result.errors - assert result.data == {"user": {"name": "Jack"}} - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(user_schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) - - type UserQuery { - user(userId: ID!): User - } - - type User @key(fields: "email") @key(fields: "userId") { - userId: ID! - email: String! - name: String - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_chat_schema(): - """ - Check that the chat schema has been annotated correctly - and that a request to retrieve a chat message works. - """ - expected_result = dedent( - """ - schema { - query: ChatQuery - } - - type ChatQuery { - message(id: ID!): ChatMessage - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type ChatMessage { - id: ID! - text: String - userId: ID - user: ChatUser! - } - - type ChatUser { - userId: ID! - } - - union _Entity = ChatUser - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(chat_schema) == clean_schema(expected_result) - - # Query the message field - query = """ - query { - message(id: "4") { - text - userId - } - } - """ - result = graphql_sync(chat_schema.graphql_schema, query) - assert not result.errors - assert result.data == {"message": {"text": "Don't be rude Jack", "userId": "3"}} - - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(chat_schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@extends", "@external", "@key"]) - type ChatQuery { - message(id: ID!): ChatMessage - } - - type ChatMessage { - id: ID! - text: String - userId: ID - user: ChatUser! - } - - extend type ChatUser @key(fields: "userId") { - userId: ID! @external - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/tests/test_shareable.py b/graphene_federation/tests/test_shareable.py deleted file mode 100644 index 9d28b5a..0000000 --- a/graphene_federation/tests/test_shareable.py +++ /dev/null @@ -1,135 +0,0 @@ -from textwrap import dedent - -import graphene -import pytest -from graphene import ObjectType -from graphql import graphql_sync - -from graphene_federation import shareable, build_schema -from graphene_federation.utils import clean_schema - - -@pytest.mark.xfail( - reason="The @Shareable directive is about indicating when an object field " - "can be resolved by multiple subgraphs. As interface fields are not " - "directly resolved (their implementation is), @Shareable is not " - "meaningful on an interface field and is not allowed (at least since " - "federation 2.2; earlier versions of federation 2 mistakenly ignored " - "@Shareable on interface fields)." -) -def test_shareable_interface_failures(): - @shareable - class ReviewInterface(graphene.Interface): - interfaced_body = graphene.String(required=True) - - @shareable - class Review(graphene.ObjectType): - class Meta: - interfaces = (ReviewInterface,) - - id = shareable(graphene.Int(required=True)) - body = graphene.String(required=True) - - class Query(ObjectType): - in_stock_count = graphene.Int(required=True) - - build_schema(query=Query, enable_federation_2=True, types=(ReviewInterface, Review)) - - -def test_shareable(): - @shareable - class Position(graphene.ObjectType): - x = graphene.Int(required=True) - y = shareable(graphene.Int(required=True)) - - class Query(ObjectType): - in_stock_count = graphene.Int(required=True) - - schema = build_schema(query=Query, enable_federation_2=True, types=(Position,)) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@shareable"]) - type Position @shareable { - x: Int! - y: Int! @shareable - } - - type Query { - inStockCount: Int! - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) - - -def test_shareable_union(): - @shareable - class Human(graphene.ObjectType): - name = graphene.String() - born_in = graphene.String() - - @shareable - class Droid(graphene.ObjectType): - name = shareable(graphene.String()) - primary_function = graphene.String() - - @shareable - class Starship(graphene.ObjectType): - name = graphene.String() - length = shareable(graphene.Int()) - - @shareable - class SearchResult(graphene.Union): - class Meta: - types = (Human, Droid, Starship) - - class Query(ObjectType): - in_stock_count = graphene.Int(required=True) - - schema = build_schema(query=Query, enable_federation_2=True, types=(SearchResult,)) - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@shareable"]) - union SearchResult @shareable = Human | Droid | Starship - - type Human @shareable { - name: String - bornIn: String - } - - type Droid @shareable { - name: String @shareable - primaryFunction: String - } - - type Starship @shareable { - name: String - length: Int @shareable - } - - type Query { - inStockCount: Int! - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/graphene_federation/transform/__init__.py b/graphene_federation/transform/__init__.py new file mode 100644 index 0000000..b03eece --- /dev/null +++ b/graphene_federation/transform/__init__.py @@ -0,0 +1 @@ +from .field_set_case_transform import field_set_case_transform diff --git a/graphene_federation/transform/field_set_case_transform.py b/graphene_federation/transform/field_set_case_transform.py new file mode 100644 index 0000000..c2f3416 --- /dev/null +++ b/graphene_federation/transform/field_set_case_transform.py @@ -0,0 +1,23 @@ +from graphene_directives import Schema + +from graphene_federation.validators import InternalNamespace, to_case + + +def field_set_case_transform(inputs: dict, schema: Schema) -> dict: + """ + Transform the fields from internal representation to schema representation + + Internal representation uses + __union__ for representing ... on + __arg__ for representing (arg1: value1, arg2: value2) + """ + fields = inputs.get("fields") + auto_case = InternalNamespace.NO_AUTO_CASE.value not in inputs.get("fields", ()) + if fields: + inputs["fields"] = ( + to_case(fields, schema, auto_case) + .replace(InternalNamespace.UNION.value, "... on") + .replace(InternalNamespace.ARG.value, "") + .replace(InternalNamespace.NO_AUTO_CASE.value, "") + ) + return inputs diff --git a/graphene_federation/utils.py b/graphene_federation/utils.py deleted file mode 100644 index f374dde..0000000 --- a/graphene_federation/utils.py +++ /dev/null @@ -1,119 +0,0 @@ -import re -from typing import Any, Callable, List, Tuple - -from graphene import ObjectType, Schema -from graphene.types.definitions import GrapheneObjectType -from graphene.types.enum import EnumOptions -from graphene.types.scalars import ScalarOptions -from graphene.types.union import UnionOptions -from graphene.utils.str_converters import to_camel_case -from graphql import GraphQLEnumType, GraphQLNonNull, GraphQLScalarType, parse - - -def field_name_to_type_attribute(schema: Schema, model: Any) -> Callable[[str], str]: - """ - Create field name conversion method (from schema name to actual graphene_type attribute name). - """ - field_names = {} - if schema.auto_camelcase: - field_names = { - to_camel_case(attr_name): attr_name - for attr_name in getattr(model._meta, "fields", []) - } - return lambda schema_field_name: field_names.get( - schema_field_name, schema_field_name - ) - - -def type_attribute_to_field_name(schema: Schema) -> Callable[[str], str]: - """ - Create a conversion method to convert from graphene_type attribute name to the schema field name. - """ - if schema.auto_camelcase: - return lambda attr_name: to_camel_case(attr_name) - else: - return lambda attr_name: attr_name - - -def check_fields_exist_on_type(fields: set, type_: ObjectType): - return fields.issubset(set(type_._meta.fields)) - - -def is_valid_compound_key(type_name: str, key: str, schema: Schema): - key_document = parse(f"{{{key}}}") - - # List storing tuples of nodes in the key document with its parent types - key_nodes: List[Tuple[Any, GrapheneObjectType]] = [ - (key_document.definitions[0], schema.graphql_schema.type_map[type_name]) - ] - - while key_nodes: - selection_node, parent_object_type = key_nodes[0] - if isinstance(parent_object_type, GraphQLNonNull): - parent_type_fields = parent_object_type.of_type.fields - else: - parent_type_fields = parent_object_type.fields - for field in selection_node.selection_set.selections: - if schema.auto_camelcase: - field_name = to_camel_case(field.name.value) - else: - field_name = field.name.value - if field_name not in parent_type_fields: - # Field does not exist on parent - return False - - field_type = parent_type_fields[field_name].type - if field.selection_set: - # If the field has sub-selections, add it to node mappings to check for valid subfields - - if isinstance(field_type, GraphQLScalarType) or ( - isinstance(field_type, GraphQLNonNull) - and isinstance(field_type.of_type, GraphQLScalarType) - ): - # sub-selections are added to a scalar type, key is not valid - return False - - key_nodes.append((field, field_type)) - else: - # If there are no sub-selections for a field, it should be a scalar or enum - if not any( - [ - ( - isinstance(field_type, GraphQLScalarType) - or isinstance(field_type, GraphQLEnumType) - ), - ( - isinstance(field_type, GraphQLNonNull) - and ( - isinstance(field_type.of_type, GraphQLScalarType) - or isinstance(field_type.of_type, GraphQLEnumType) - ) - ), - ] - ): - return False - - key_nodes.pop(0) # Remove the current node as it is fully processed - - return True - - -def get_attributed_fields(attribute: str, schema: Schema): - fields = {} - for type_name, type_ in schema.graphql_schema.type_map.items(): - if ( - not hasattr(type_, "graphene_type") - or isinstance(type_.graphene_type._meta, UnionOptions) - or isinstance(type_.graphene_type._meta, ScalarOptions) - or isinstance(type_.graphene_type._meta, EnumOptions) - ): - continue - for field in list(type_.graphene_type._meta.fields): - if getattr(getattr(type_.graphene_type, field, None), attribute, False): - fields[type_name] = type_.graphene_type - continue - return fields - - -def clean_schema(schema): - return re.sub(r"[ \n]+", " ", str(schema)).strip() diff --git a/graphene_federation/validators/__init__.py b/graphene_federation/validators/__init__.py new file mode 100644 index 0000000..9f13202 --- /dev/null +++ b/graphene_federation/validators/__init__.py @@ -0,0 +1,5 @@ +from .key import validate_key +from .provides import validate_provides +from .requires import validate_requires +from .utils import InternalNamespace +from .utils import ast_to_str, build_ast, to_case diff --git a/graphene_federation/validators/key.py b/graphene_federation/validators/key.py new file mode 100644 index 0000000..cba49d9 --- /dev/null +++ b/graphene_federation/validators/key.py @@ -0,0 +1,31 @@ +from typing import Union + +from graphene import Field, Interface, ObjectType +from graphene_directives import Schema + +from .utils import InternalNamespace, build_ast, evaluate_ast, to_case + + +def validate_key( + graphene_type: Union[ObjectType, Interface, Field], inputs: dict, schema: Schema +) -> bool: + """ + Used to validate the inputs and graphene_type of @key + """ + errors: list[str] = [] + auto_case = InternalNamespace.NO_AUTO_CASE.value not in inputs.get("fields", ()) + ast_node = build_ast( + fields=to_case(inputs.get("fields"), schema, auto_case), directive_name="@key" + ) + evaluate_ast( + directive_name="@key", + ast=ast_node, + graphene_type=graphene_type, + ignore_fields=[InternalNamespace.NO_AUTO_CASE.value], + errors=errors, + entity_types=schema.graphql_schema.type_map, + ) + if errors: + raise ValueError("\n".join(errors)) + + return True diff --git a/graphene_federation/validators/provides.py b/graphene_federation/validators/provides.py new file mode 100644 index 0000000..4066981 --- /dev/null +++ b/graphene_federation/validators/provides.py @@ -0,0 +1,56 @@ +from typing import Union + +from graphene import Field, Interface, ObjectType +from graphene_directives import Schema + +from .utils import ( + InternalNamespace, + build_ast, + evaluate_ast, + to_case, +) + + +def validate_provides( + _parent_type: Union[ObjectType, Interface], + field: Field, + inputs: dict, + schema: Schema, +) -> bool: + """ + Used to validate the inputs and graphene_type of @provides + """ + errors: list[str] = [] + auto_case = InternalNamespace.NO_AUTO_CASE.value not in inputs.get("fields", ()) + ast_node = build_ast( + fields=to_case(inputs.get("fields"), schema, auto_case), + directive_name="@provides", + ) + + # Get the parent type of the field + field_parent_type = field + while hasattr(field_parent_type, "type") or hasattr(field_parent_type, "of_type"): + if hasattr(field_parent_type, "of_type"): + field_parent_type = field_parent_type.of_type + elif hasattr(field_parent_type, "type"): + field_parent_type = field_parent_type.type + else: + raise ValueError( + f"@provides could not find parent for the field {field} at {_parent_type}" + ) + + evaluate_ast( + directive_name="@provides", + ast=ast_node, + graphene_type=field_parent_type, + ignore_fields=[ + InternalNamespace.UNION.value, + InternalNamespace.NO_AUTO_CASE.value, + ], + errors=errors, + entity_types=schema.graphql_schema.type_map, + ) + if errors: + raise ValueError("\n".join(errors)) + + return True diff --git a/graphene_federation/validators/requires.py b/graphene_federation/validators/requires.py new file mode 100644 index 0000000..0aa8203 --- /dev/null +++ b/graphene_federation/validators/requires.py @@ -0,0 +1,39 @@ +from typing import Union + +from graphene import Field, Interface, ObjectType +from graphene_directives import Schema + +from .utils import InternalNamespace, build_ast, evaluate_ast, to_case + + +def validate_requires( + parent_type: Union[ObjectType, Interface], + _field: Field, + inputs: dict, + schema: Schema, +) -> bool: + """ + Used to validate the inputs and graphene_type of @requires + """ + errors: list[str] = [] + auto_case = InternalNamespace.NO_AUTO_CASE.value not in inputs.get("fields", ()) + ast_node = build_ast( + fields=to_case(inputs.get("fields"), schema, auto_case), + directive_name="@requires", + ) + evaluate_ast( + directive_name="@requires", + ast=ast_node, + graphene_type=parent_type, + ignore_fields=[ + "__typename", + InternalNamespace.NO_AUTO_CASE.value, + InternalNamespace.UNION.value, + ], + errors=errors, + entity_types=schema.graphql_schema.type_map, + ) + if errors: + raise ValueError("\n".join(errors)) + + return True diff --git a/graphene_federation/validators/utils.py b/graphene_federation/validators/utils.py new file mode 100644 index 0000000..a812fae --- /dev/null +++ b/graphene_federation/validators/utils.py @@ -0,0 +1,387 @@ +from enum import Enum +from typing import Union + +from graphene import Field, Interface, NonNull, ObjectType +from graphene.types.definitions import ( + GrapheneEnumType, + GrapheneInterfaceType, + GrapheneObjectType, + GrapheneScalarType, + GrapheneUnionType, +) +from graphene.utils.str_converters import to_camel_case +from graphene_directives import Schema +from graphql import ( + GraphQLField, + GraphQLList, + GraphQLNonNull, + GraphQLScalarType, + GraphQLType, +) + +""" +@requires, @key, @provides 's field is represented internally in a different way + +A field definition + +"id currency(curreny_value: usd) products{ ... on Bag { id } ... on Cloth { id } }" + +is internally represented as + +"id currency __arg__(curreny_value: usd) products{ __union__ Bag { id } __union__ Cloth { id } }" +""" + + +class InternalNamespace(Enum): + UNION = "__union__" + ARG = "__arg__" + NO_AUTO_CASE = "__no_auto_case__" + + +def check_fields_exist_on_type( + field: str, + graphene_type: Union[ObjectType, Interface, Field, NonNull], + ignore_fields: list[str], + entity_types: dict[str, ObjectType], +) -> Union[GraphQLType, GraphQLField, bool]: + """ + Checks if the given field exists on the graphene_type + + :param field: field that needs to be checked for existence + :param graphene_type: Union[ObjectType, Interface, Field, NonNull] + :param ignore_fields: fields that can be ignored for checking example __typename + :param entity_types: A dictionary of [entity_name, graphene_type] + """ + if field in ignore_fields or field.startswith( + "__arg__" # todo handle argument type validations + ): + return True + + if isinstance(graphene_type, GraphQLField): + return check_fields_exist_on_type( + field, + graphene_type.type, # noqa + ignore_fields, + entity_types, + ) + if isinstance(graphene_type, GraphQLNonNull): + return check_fields_exist_on_type( + field, graphene_type.of_type, ignore_fields, entity_types + ) + if isinstance(graphene_type, GrapheneObjectType): + if field in graphene_type.fields: + return graphene_type.fields[field] + if isinstance(graphene_type, GraphQLList): + return check_fields_exist_on_type( + field, graphene_type.of_type, ignore_fields, entity_types + ) + if isinstance(graphene_type, GrapheneUnionType): + for union_type in graphene_type.types: + if union_type.name.lower() == field.lower(): + return union_type + try: + if issubclass( + graphene_type, # noqa + ObjectType, + ) or issubclass( + graphene_type, # noqa + Interface, + ): + entity_fields = entity_types.get(graphene_type._meta.name) # noqa + if entity_fields is not None: + entity_fields = entity_fields.fields # noqa + if field in entity_fields: + return entity_fields[field] + except TypeError: + return False + + return False + + +def get_type_for_field( + graphene_field, +) -> tuple[ + Union[ + GrapheneObjectType, + GrapheneInterfaceType, + GrapheneUnionType, + GrapheneScalarType, + GrapheneEnumType, + ], + bool, +]: + """ + Finds the base type for a given graphene_field + + Returns the graphene_field_type, is_selectable (indicates whether the type has sub selections) + """ + if isinstance(graphene_field, GraphQLField): + return get_type_for_field(graphene_field.type) + if isinstance(graphene_field, GraphQLNonNull): + return get_type_for_field(graphene_field.of_type) + if isinstance(graphene_field, GraphQLList): + return get_type_for_field(graphene_field.of_type) + if ( + isinstance(graphene_field, GrapheneObjectType) + or isinstance(graphene_field, GrapheneInterfaceType) + or isinstance(graphene_field, GrapheneUnionType) + ): + return graphene_field, True + if isinstance(graphene_field, GraphQLScalarType) or isinstance( + graphene_field, GrapheneEnumType + ): + return graphene_field, False + + raise NotImplementedError("get_type_for_field", graphene_field) + + +"""" +AST FUNCTIONS + +For @key, @provides, @requires FieldSet Parsing +""" + + +def _tokenize_field_set(fields: str, directive_name: str) -> list[str]: + """ + Splits the fields string to tokens + """ + + fields = fields.strip() + tokens = [] + current_token = "" + open_braces_count = 0 + + if fields.startswith("{"): + raise ValueError(f"{directive_name} cannot start with " + "{") + + index = 0 + while index < len(fields): + char = fields[index] + if char.isalnum(): + current_token += char + elif char == "{": + if current_token: + tokens.append(current_token) + tokens.append(char) + current_token = "" + open_braces_count += 1 + elif char == "}": + if current_token: + tokens.append(current_token) + tokens.append(char) + current_token = "" + open_braces_count -= 1 + elif char == ",": + if current_token: + tokens.append(current_token) + current_token = "" + elif char == "_": + current_token += char + elif char == "(": + tokens.append(current_token) + current_token = f"{char}" + index += 1 + mismatched_parenthesis = True + while index < len(fields): + char = fields[index] + if char.isalnum() or char == ",": + current_token += char + elif char.isspace(): + index += 1 + continue + elif char == ":": + current_token += ": " + elif char == ")": + current_token += char + mismatched_parenthesis = False + tokens.append( + ", ".join(current_token.split(",")).replace("(", "__arg__(") + ) + current_token = "" + break + else: + ValueError( + f"{directive_name}({fields}) has unknown character {char} at argument {current_token}" + ) + index += 1 + if mismatched_parenthesis: + raise ValueError( + f"{directive_name}({fields}) has mismatched parenthesis" + ) + elif char == ")": + raise ValueError(f"{directive_name}({fields}) has mismatched parenthesis") + elif char.isspace(): + if current_token == "on": + tokens.append("__union__") + elif current_token: + tokens.append(current_token) + current_token = "" + else: + if current_token: + tokens.append(current_token) + current_token = "" + + index += 1 + + if current_token: + tokens.append(current_token) + + if open_braces_count != 0: + raise ValueError(f"{directive_name}({fields}) has mismatched brackets") + + return tokens + + +def evaluate_ast( + directive_name: str, + ast: dict, + graphene_type: ObjectType, + ignore_fields: list[str], + errors: list[str], + entity_types: dict[str, ObjectType], +) -> None: + """ + Checks if the given AST is valid for the graphene_type + + It recursively checks if the fields at a node exist on the graphene_type + """ + for field_name, value in ast.items(): + field_type = check_fields_exist_on_type( + field_name, + graphene_type, + ignore_fields, + entity_types, + ) + has_selections = len(value) != 0 + + if not field_type: + errors.append( + f'{directive_name}, field "{field_name}" does not exist on type "{graphene_type}"' + ) + continue + + field_type, is_selectable = ( + get_type_for_field(field_type) + if not isinstance(field_type, bool) + else ( + field_type, + False, + ) + ) + + if is_selectable and not has_selections: + errors.append( + f'{directive_name}, type {graphene_type}, field "{field_name}" needs sub selections.' + ) + continue + + if not is_selectable and has_selections: + errors.append( + f'{directive_name}, type {graphene_type}, field "{field_name}" cannot have sub selections.' + ) + continue + + if len(value) != 0: + evaluate_ast( + directive_name, + value, + field_type, + ignore_fields, + errors, + entity_types, + ) + + +def build_ast(fields: str, directive_name: str) -> dict: + """ + Converts the fields string to an AST tree + + :param fields: string fields + :param directive_name: name of the directive + """ + cleaned_fields = _tokenize_field_set(fields, directive_name) + + parent: dict[str, dict] = {} + field_stack: list[str] = [] + field_level = [parent] + for index, field in enumerate(cleaned_fields): + if field == "{": + field_level.append(field_level[-1][field_stack[-1]]) + elif field == "}": + field_level.pop() + else: + field_stack.append(field) + field_level[-1][field] = {} + return parent + + +def ast_to_str(fields: dict, add_type_name: bool = False, level: int = 0) -> str: + """ + Converts the AST of fields to the original string + + :param fields: AST of fields + :param add_type_name: adds __typename to sub ast nodes (for @requires) + :param level: for internal use only + """ + + new_fields = [] + union_type = False + if level != 0 and add_type_name: + new_fields.append("__typename") + for field, value in fields.items(): + if "typename" in field.lower(): + continue + if "__union__" in field.lower(): + union_type = True + elif len(value) == 0: + new_fields.append(field) + else: + inner_fields = [ + field, + "{", + ast_to_str(value, add_type_name, level + 1), + "}", + ] + if union_type: + inner_fields.insert(0, "... on") + new_fields.extend(inner_fields) + + return " ".join(new_fields) + + +"""" +String Helpers + +For Schema Field Casing Parsing +""" + + +def to_case(fields: Union[str, None], schema: Schema, auto_case: bool = True) -> str: + """ + Converts field str to correct casing according to the schema.auto_camelcase value + """ + if not fields: + return "" + + skip_next = False + + if schema.auto_camelcase and auto_case: + data_fields = [] + for field in fields.split(): + if field == InternalNamespace.UNION.value: + data_fields.append(field) + skip_next = True + elif field == "__typename": + data_fields.append(field) + elif field.startswith(InternalNamespace.ARG.value): + data_fields.append(field) + else: + if skip_next: + data_fields.append(field) + skip_next = False + else: + data_fields.append(to_camel_case(field)) + return " ".join(data_fields) + + return fields diff --git a/integration_tests/service_a/src/schema.py b/integration_tests/service_a/src/schema.py index 60b9870..78c8597 100644 --- a/integration_tests/service_a/src/schema.py +++ b/integration_tests/service_a/src/schema.py @@ -1,18 +1,20 @@ -from graphene import ObjectType, String, Int, List, NonNull, Field, Interface +from graphene import Field, Int, Interface, List, NonNull, ObjectType, String -from graphene_federation import build_schema, extend, external +from graphene_federation import FederationVersion, build_schema, extends, external, key class DecoratedText(Interface): color = Int(required=True) -@extend(fields="id") +@key(fields="id") +@extends class FileNode(ObjectType): id = external(Int(required=True)) -@extend(fields="id") +@key(fields="id") +@extends class FunnyText(ObjectType): class Meta: interfaces = (DecoratedText,) @@ -37,7 +39,8 @@ def resolve_color(self, info, **kwargs): return self.id + 2 -@extend(fields="primaryEmail") +@key(fields="primaryEmail") +@extends class User(ObjectType): primaryEmail = external(String()) @@ -76,4 +79,9 @@ def resolve_goodbye(root, info): return "See ya!" -schema = build_schema(query=Query, types=[FunnyTextAnother], auto_camelcase=False) +schema = build_schema( + query=Query, + types=[FunnyTextAnother], + federation_version=FederationVersion.VERSION_1_0, + auto_camelcase=False, +) diff --git a/integration_tests/service_b/src/schema.py b/integration_tests/service_b/src/schema.py index 9c31e15..96fca37 100644 --- a/integration_tests/service_b/src/schema.py +++ b/integration_tests/service_b/src/schema.py @@ -1,6 +1,6 @@ -from graphene import ObjectType, String, Int, Interface, Mutation +from graphene import Int, Interface, Mutation, ObjectType, String -from graphene_federation import build_schema, key +from graphene_federation import FederationVersion, build_schema, key class TextInterface(Interface): @@ -70,4 +70,6 @@ class Mutation(ObjectType): types = [FileNode, FunnyText, FileNodeAnother, User] -schema = build_schema(mutation=Mutation, types=types) +schema = build_schema( + mutation=Mutation, types=types, federation_version=FederationVersion.VERSION_1_0 +) diff --git a/integration_tests/service_c/src/schema.py b/integration_tests/service_c/src/schema.py index ecf4489..cf0b5c6 100644 --- a/integration_tests/service_c/src/schema.py +++ b/integration_tests/service_c/src/schema.py @@ -1,9 +1,18 @@ -from graphene import ObjectType, String, Int, List, NonNull, Field +from graphene import Field, Int, List, NonNull, ObjectType, String -from graphene_federation import build_schema, extend, external, requires, key, provides +from graphene_federation import ( + FederationVersion, + build_schema, + extends, + external, + key, + provides, + requires, +) -@extend(fields="id") +@key(fields="id") +@extends class User(ObjectType): id = external(Int(required=True)) primary_email = external(String()) @@ -27,7 +36,6 @@ def __resolve_reference(self, info, **kwargs): return Article(id=self.id, text=f"text_{self.id}") -@provides class ArticleThatProvideAuthorAge(ObjectType): """ should not contain other graphene-federation decorators to proper test test-case @@ -54,4 +62,4 @@ def resolve_articles_with_author_age_provide(self, info): return [ArticleThatProvideAuthorAge(id=1, text="some text", author=User(id=5))] -schema = build_schema(Query) +schema = build_schema(Query, federation_version=FederationVersion.VERSION_1_0) diff --git a/integration_tests/service_d/src/schema.py b/integration_tests/service_d/src/schema.py index 2371047..74d7b4c 100644 --- a/integration_tests/service_d/src/schema.py +++ b/integration_tests/service_d/src/schema.py @@ -1,6 +1,6 @@ -from graphene import ObjectType, Int, Field +from graphene import Field, Int, ObjectType -from graphene_federation import build_schema, extend, external +from graphene_federation import FederationVersion, build_schema, extends, external, key """ Alphabet order - matters @@ -9,7 +9,8 @@ """ -@extend(fields="id") +@key(fields="id") +@extends class Article(ObjectType): id = external(Int(required=True)) @@ -27,4 +28,4 @@ class Query(ObjectType): y = Field(Y) -schema = build_schema(query=Query) +schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) diff --git a/integration_tests/tests/tests/test_main.py b/integration_tests/tests/tests/test_main.py index 491059c..4df72bf 100644 --- a/integration_tests/tests/tests/test_main.py +++ b/integration_tests/tests/tests/test_main.py @@ -1,4 +1,5 @@ import json + import requests @@ -127,7 +128,7 @@ def test_multiple_key_decorators_apply_multiple_key_annotations(): def test_avoid_duplication_of_key_decorator(): sdl = fetch_sdl("service_a") - assert 'extend type FileNode @key(fields: "id") {' in sdl + assert 'type FileNode @key(fields: "id") @extends {' in sdl def test_requires(): diff --git a/setup.py b/setup.py index 144e616..789a330 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ import os + from setuptools import find_packages, setup @@ -14,7 +15,7 @@ def read(*rnames): ] dev_require = [ - "black==22.3.0", + "black==23.12.1", "flake8==4.0.1", "mypy==0.961", ] + tests_require @@ -35,6 +36,7 @@ def read(*rnames): install_requires=[ "graphene>=3.1", "graphql-core>=3.1", + "graphene-directives>=0.4.6", ], classifiers=[ "Development Status :: 5 - Production/Stable", @@ -46,6 +48,7 @@ def read(*rnames): "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], extras_require={ "test": tests_require, diff --git a/graphene_federation/tests/__init__.py b/tests/__init__.py similarity index 100% rename from graphene_federation/tests/__init__.py rename to tests/__init__.py diff --git a/tests/gql/test_annotation_corner_cases/test_annotate_object_with_meta_name_1.graphql b/tests/gql/test_annotation_corner_cases/test_annotate_object_with_meta_name_1.graphql new file mode 100644 index 0000000..b89ff4a --- /dev/null +++ b/tests/gql/test_annotation_corner_cases/test_annotate_object_with_meta_name_1.graphql @@ -0,0 +1,36 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key"]) + +type Query { + a: Banana + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Banana @extends { + id: ID @external + b(id: ID ): Potato +} + +type Potato @key(fields: "id") { + id: ID +} + +union _Entity = Banana | Potato + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases/test_annotate_object_with_meta_name_2.graphql b/tests/gql/test_annotation_corner_cases/test_annotate_object_with_meta_name_2.graphql new file mode 100644 index 0000000..562cbf8 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases/test_annotate_object_with_meta_name_2.graphql @@ -0,0 +1,26 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key"]) + +type Query { + a: Banana +} + +type Banana @extends { + id: ID @external + b(id: ID ): Potato +} + +type Potato @key(fields: "id") { + id: ID +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases/test_annotated_field_also_used_in_filter_1.graphql b/tests/gql/test_annotation_corner_cases/test_annotated_field_also_used_in_filter_1.graphql new file mode 100644 index 0000000..b1c768d --- /dev/null +++ b/tests/gql/test_annotation_corner_cases/test_annotated_field_also_used_in_filter_1.graphql @@ -0,0 +1,36 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key"]) + +type Query { + a: A + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type A @extends { + id: ID @external + b(id: ID ): B +} + +type B @key(fields: "id") { + id: ID +} + +union _Entity = A | B + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases/test_annotated_field_also_used_in_filter_2.graphql b/tests/gql/test_annotation_corner_cases/test_annotated_field_also_used_in_filter_2.graphql new file mode 100644 index 0000000..a19de00 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases/test_annotated_field_also_used_in_filter_2.graphql @@ -0,0 +1,26 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key"]) + +type Query { + a: A +} + +type A @extends { + id: ID @external + b(id: ID ): B +} + +type B @key(fields: "id") { + id: ID +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_1.graphql b/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_1.graphql new file mode 100644 index 0000000..53b2a74 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_1.graphql @@ -0,0 +1,34 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key", "@requires"]) + +type Query { + camel: Camel + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Camel @key(fields: "autoCamel") @extends { + autoCamel: String @external + forcedCamel: String @requires(fields: "autoCamel") + aSnake: String + aCamel: String +} + +union _Entity = Camel + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_2.graphql b/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_2.graphql new file mode 100644 index 0000000..f57b03e --- /dev/null +++ b/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_2.graphql @@ -0,0 +1,24 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key", "@requires"]) + +type Query { + camel: Camel +} + +type Camel @key(fields: "autoCamel") @extends { + autoCamel: String @external + forcedCamel: String @requires(fields: "autoCamel") + aSnake: String + aCamel: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_without_auto_camelcase_1.graphql b/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_without_auto_camelcase_1.graphql new file mode 100644 index 0000000..45dddc2 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_without_auto_camelcase_1.graphql @@ -0,0 +1,34 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@requires"]) + +type Query { + camel: Camel + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Camel @extends { + auto_camel: String @external + forcedCamel: String @requires(fields: "auto_camel") + a_snake: String + aCamel: String +} + +union _Entity = Camel + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_without_auto_camelcase_2.graphql b/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_without_auto_camelcase_2.graphql new file mode 100644 index 0000000..8edc83c --- /dev/null +++ b/tests/gql/test_annotation_corner_cases/test_camel_case_field_name_without_auto_camelcase_2.graphql @@ -0,0 +1,24 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@requires"]) + +type Query { + camel: Camel +} + +type Camel @extends { + auto_camel: String @external + forcedCamel: String @requires(fields: "auto_camel") + a_snake: String + aCamel: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases/test_similar_field_name_1.graphql b/tests/gql/test_annotation_corner_cases/test_similar_field_name_1.graphql new file mode 100644 index 0000000..c02d8d3 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases/test_similar_field_name_1.graphql @@ -0,0 +1,44 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key"]) + +schema { + query: ChatQuery +} + +type ChatQuery { + message(id: ID!): ChatMessage + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type ChatMessage { + id: ID! + user: ChatUser +} + +type ChatUser @key(fields: "id") @extends { + uid: ID + identified: ID + id: ID @external + iD: ID + ID: ID +} + +union _Entity = ChatUser + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases/test_similar_field_name_2.graphql b/tests/gql/test_annotation_corner_cases/test_similar_field_name_2.graphql new file mode 100644 index 0000000..0bc8d56 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases/test_similar_field_name_2.graphql @@ -0,0 +1,34 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key"]) + +schema { + query: ChatQuery +} + +type ChatQuery { + message(id: ID!): ChatMessage +} + +type ChatMessage { + id: ID! + user: ChatUser +} + +type ChatUser @key(fields: "id") @extends { + uid: ID + identified: ID + id: ID @external + iD: ID + ID: ID +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases_v1/test_annotate_object_with_meta_name_1.graphql b/tests/gql/test_annotation_corner_cases_v1/test_annotate_object_with_meta_name_1.graphql new file mode 100644 index 0000000..ab0f8dd --- /dev/null +++ b/tests/gql/test_annotation_corner_cases_v1/test_annotate_object_with_meta_name_1.graphql @@ -0,0 +1,27 @@ +type Query { + a: Banana + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Banana @extends { + id: ID @external + b(id: ID ): Potato +} + +type Potato @key(fields: "id") { + id: ID +} + +union _Entity = Banana | Potato + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases_v1/test_annotate_object_with_meta_name_2.graphql b/tests/gql/test_annotation_corner_cases_v1/test_annotate_object_with_meta_name_2.graphql new file mode 100644 index 0000000..d0fb5b5 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases_v1/test_annotate_object_with_meta_name_2.graphql @@ -0,0 +1,17 @@ +type Query { + a: Banana +} + +type Banana @extends { + id: ID @external + b(id: ID ): Potato +} + +type Potato @key(fields: "id") { + id: ID +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases_v1/test_annotated_field_also_used_in_filter_1.graphql b/tests/gql/test_annotation_corner_cases_v1/test_annotated_field_also_used_in_filter_1.graphql new file mode 100644 index 0000000..27b7f2e --- /dev/null +++ b/tests/gql/test_annotation_corner_cases_v1/test_annotated_field_also_used_in_filter_1.graphql @@ -0,0 +1,27 @@ +type Query { + a: A + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type A @extends { + id: ID @external + b(id: ID ): B +} + +type B @key(fields: "id") { + id: ID +} + +union _Entity = A | B + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases_v1/test_annotated_field_also_used_in_filter_2.graphql b/tests/gql/test_annotation_corner_cases_v1/test_annotated_field_also_used_in_filter_2.graphql new file mode 100644 index 0000000..2672e53 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases_v1/test_annotated_field_also_used_in_filter_2.graphql @@ -0,0 +1,17 @@ +type Query { + a: A +} + +type A @extends { + id: ID @external + b(id: ID ): B +} + +type B @key(fields: "id") { + id: ID +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_1.graphql b/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_1.graphql new file mode 100644 index 0000000..7a811f7 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_1.graphql @@ -0,0 +1,25 @@ +type Query { + camel: Camel + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Camel @key(fields: "autoCamel") @extends { + autoCamel: String @external + forcedCamel: String @requires(fields: "autoCamel") + aSnake: String + aCamel: String +} + +union _Entity = Camel + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_2.graphql b/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_2.graphql new file mode 100644 index 0000000..7f1c08b --- /dev/null +++ b/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_2.graphql @@ -0,0 +1,15 @@ +type Query { + camel: Camel +} + +type Camel @key(fields: "autoCamel") @extends { + autoCamel: String @external + forcedCamel: String @requires(fields: "autoCamel") + aSnake: String + aCamel: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_without_auto_camelcase_1.graphql b/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_without_auto_camelcase_1.graphql new file mode 100644 index 0000000..81cbc36 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_without_auto_camelcase_1.graphql @@ -0,0 +1,25 @@ +type Query { + camel: Camel + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Camel @extends { + auto_camel: String @external + forcedCamel: String @requires(fields: "auto_camel") + a_snake: String + aCamel: String +} + +union _Entity = Camel + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_without_auto_camelcase_2.graphql b/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_without_auto_camelcase_2.graphql new file mode 100644 index 0000000..8ac3be1 --- /dev/null +++ b/tests/gql/test_annotation_corner_cases_v1/test_camel_case_field_name_without_auto_camelcase_2.graphql @@ -0,0 +1,15 @@ +type Query { + camel: Camel +} + +type Camel @extends { + auto_camel: String @external + forcedCamel: String @requires(fields: "auto_camel") + a_snake: String + aCamel: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases_v1/test_similar_field_name_1.graphql b/tests/gql/test_annotation_corner_cases_v1/test_similar_field_name_1.graphql new file mode 100644 index 0000000..765074e --- /dev/null +++ b/tests/gql/test_annotation_corner_cases_v1/test_similar_field_name_1.graphql @@ -0,0 +1,35 @@ +schema { + query: ChatQuery +} + +type ChatQuery { + message(id: ID!): ChatMessage + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type ChatMessage { + id: ID! + user: ChatUser +} + +type ChatUser @key(fields: "id") @extends { + uid: ID + identified: ID + id: ID @external + iD: ID + ID: ID +} + +union _Entity = ChatUser + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_annotation_corner_cases_v1/test_similar_field_name_2.graphql b/tests/gql/test_annotation_corner_cases_v1/test_similar_field_name_2.graphql new file mode 100644 index 0000000..ca4b8ef --- /dev/null +++ b/tests/gql/test_annotation_corner_cases_v1/test_similar_field_name_2.graphql @@ -0,0 +1,25 @@ +schema { + query: ChatQuery +} + +type ChatQuery { + message(id: ID!): ChatMessage +} + +type ChatMessage { + id: ID! + user: ChatUser +} + +type ChatUser @key(fields: "id") @extends { + uid: ID + identified: ID + id: ID @external + iD: ID + ID: ID +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_custom_enum/test_custom_enum_1.graphql b/tests/gql/test_custom_enum/test_custom_enum_1.graphql new file mode 100644 index 0000000..dc39872 --- /dev/null +++ b/tests/gql/test_custom_enum/test_custom_enum_1.graphql @@ -0,0 +1,34 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@inaccessible", "@shareable"]) + +type TestCustomEnum @shareable { + testShareableScalar: Episode @shareable + testInaccessibleScalar: Episode @inaccessible +} + +enum Episode @inaccessible { + NEWHOPE @inaccessible + EMPIRE + JEDI +} + +type Query { + test: Episode + test2: [TestCustomEnum]! + _service: _Service! +} + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_custom_enum/test_custom_enum_2.graphql b/tests/gql/test_custom_enum/test_custom_enum_2.graphql new file mode 100644 index 0000000..e9f3d2e --- /dev/null +++ b/tests/gql/test_custom_enum/test_custom_enum_2.graphql @@ -0,0 +1,29 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@inaccessible", "@shareable"]) + +type TestCustomEnum @shareable { + testShareableScalar: Episode @shareable + testInaccessibleScalar: Episode @inaccessible +} + +enum Episode @inaccessible { + NEWHOPE @inaccessible + EMPIRE + JEDI +} + +type Query { + test: Episode + test2: [TestCustomEnum]! +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_inaccessible/test_inaccessible_1.graphql b/tests/gql/test_inaccessible/test_inaccessible_1.graphql new file mode 100644 index 0000000..adbf745 --- /dev/null +++ b/tests/gql/test_inaccessible/test_inaccessible_1.graphql @@ -0,0 +1,27 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@inaccessible"]) + +type Position @inaccessible { + x: Int! + y: Int! @inaccessible +} + +type Query { + inStockCount: Int! + _service: _Service! +} + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_inaccessible/test_inaccessible_2.graphql b/tests/gql/test_inaccessible/test_inaccessible_2.graphql new file mode 100644 index 0000000..9bd20e8 --- /dev/null +++ b/tests/gql/test_inaccessible/test_inaccessible_2.graphql @@ -0,0 +1,22 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@inaccessible"]) + +type Position @inaccessible { + x: Int! + y: Int! @inaccessible +} + +type Query { + inStockCount: Int! +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_inaccessible/test_inaccessible_union_1.graphql b/tests/gql/test_inaccessible/test_inaccessible_union_1.graphql new file mode 100644 index 0000000..eb0927d --- /dev/null +++ b/tests/gql/test_inaccessible/test_inaccessible_union_1.graphql @@ -0,0 +1,39 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@inaccessible"]) + +union SearchResult @inaccessible = Human | Droid | Starship + +type Human @inaccessible { + name: String + bornIn: String +} + +type Droid @inaccessible { + name: String @inaccessible + primaryFunction: String +} + +type Starship @inaccessible { + name: String + length: Int @inaccessible +} + +type Query { + inStockCount: Int! + _service: _Service! +} + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_inaccessible/test_inaccessible_union_2.graphql b/tests/gql/test_inaccessible/test_inaccessible_union_2.graphql new file mode 100644 index 0000000..a9403a5 --- /dev/null +++ b/tests/gql/test_inaccessible/test_inaccessible_union_2.graphql @@ -0,0 +1,34 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@inaccessible"]) + +union SearchResult @inaccessible = Human | Droid | Starship + +type Human @inaccessible { + name: String + bornIn: String +} + +type Droid @inaccessible { + name: String @inaccessible + primaryFunction: String +} + +type Starship @inaccessible { + name: String + length: Int @inaccessible +} + +type Query { + inStockCount: Int! +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_key/test_compound_primary_key_1.graphql b/tests/gql/test_key/test_compound_primary_key_1.graphql new file mode 100644 index 0000000..3bcc601 --- /dev/null +++ b/tests/gql/test_key/test_compound_primary_key_1.graphql @@ -0,0 +1,36 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + +type Query { + user: User + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type User @key(fields: "id organization { registrationNumber }") { + id: ID + organization: Organization +} + +type Organization { + registrationNumber: ID +} + +union _Entity = User + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_key/test_compound_primary_key_2.graphql b/tests/gql/test_key/test_compound_primary_key_2.graphql new file mode 100644 index 0000000..3c48134 --- /dev/null +++ b/tests/gql/test_key/test_compound_primary_key_2.graphql @@ -0,0 +1,26 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + +type Query { + user: User +} + +type User @key(fields: "id organization { registrationNumber }") { + id: ID + organization: Organization +} + +type Organization { + registrationNumber: ID +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_key/test_compound_primary_key_with_depth_1.graphql b/tests/gql/test_key/test_compound_primary_key_with_depth_1.graphql new file mode 100644 index 0000000..c8dfebd --- /dev/null +++ b/tests/gql/test_key/test_compound_primary_key_with_depth_1.graphql @@ -0,0 +1,42 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + +type Query { + user: User + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type User @key(fields: "id organization { businessUnit { id name } }") { + id: ID + organization: Organization +} + +type Organization { + registrationNumber: ID + businessUnit: BusinessUnit +} + +type BusinessUnit { + id: ID + name: String +} + +union _Entity = User + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_key/test_compound_primary_key_with_depth_2.graphql b/tests/gql/test_key/test_compound_primary_key_with_depth_2.graphql new file mode 100644 index 0000000..54430d3 --- /dev/null +++ b/tests/gql/test_key/test_compound_primary_key_with_depth_2.graphql @@ -0,0 +1,32 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + +type Query { + user: User +} + +type User @key(fields: "id organization { businessUnit { id name } }") { + id: ID + organization: Organization +} + +type Organization { + registrationNumber: ID + businessUnit: BusinessUnit +} + +type BusinessUnit { + id: ID + name: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_key/test_multiple_keys_1.graphql b/tests/gql/test_key/test_multiple_keys_1.graphql new file mode 100644 index 0000000..9e1d066 --- /dev/null +++ b/tests/gql/test_key/test_multiple_keys_1.graphql @@ -0,0 +1,32 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + +type Query { + user: User + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type User @key(fields: "email") @key(fields: "identifier") { + identifier: ID + email: String +} + +union _Entity = User + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_key/test_multiple_keys_2.graphql b/tests/gql/test_key/test_multiple_keys_2.graphql new file mode 100644 index 0000000..815b80c --- /dev/null +++ b/tests/gql/test_key/test_multiple_keys_2.graphql @@ -0,0 +1,22 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + +type Query { + user: User +} + +type User @key(fields: "email") @key(fields: "identifier") { + identifier: ID + email: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_key_v1/test_multiple_keys_1.graphql b/tests/gql/test_key_v1/test_multiple_keys_1.graphql new file mode 100644 index 0000000..413f025 --- /dev/null +++ b/tests/gql/test_key_v1/test_multiple_keys_1.graphql @@ -0,0 +1,23 @@ +type Query { + user: User + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type User @key(fields: "email") @key(fields: "identifier") { + identifier: ID + email: String +} + +union _Entity = User + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_key_v1/test_multiple_keys_2.graphql b/tests/gql/test_key_v1/test_multiple_keys_2.graphql new file mode 100644 index 0000000..62870c2 --- /dev/null +++ b/tests/gql/test_key_v1/test_multiple_keys_2.graphql @@ -0,0 +1,13 @@ +type Query { + user: User +} + +type User @key(fields: "email") @key(fields: "identifier") { + identifier: ID + email: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_override/test_override_1.graphql b/tests/gql/test_override/test_override_1.graphql new file mode 100644 index 0000000..86554c8 --- /dev/null +++ b/tests/gql/test_override/test_override_1.graphql @@ -0,0 +1,28 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@override"]) + +type Query { + product: Product + _service: _Service! +} + +type Product { + sku: ID @override(from: "subgraph-1") + size: Int @override(from: "subgraph-2") + weight: Int @override(from: "subgraph-3", label: "Test label") +} + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_override/test_override_2.graphql b/tests/gql/test_override/test_override_2.graphql new file mode 100644 index 0000000..3d717b6 --- /dev/null +++ b/tests/gql/test_override/test_override_2.graphql @@ -0,0 +1,23 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@override"]) + +type Query { + product: Product +} + +type Product { + sku: ID @override(from: "subgraph-1") + size: Int @override(from: "subgraph-2") + weight: Int @override(from: "subgraph-3", label: "Test label") +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_provides/test_provides_1.graphql b/tests/gql/test_provides/test_provides_1.graphql new file mode 100644 index 0000000..aa03470 --- /dev/null +++ b/tests/gql/test_provides/test_provides_1.graphql @@ -0,0 +1,38 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@external", "@key", "@provides"]) + +type Query { + inStockCount: InStockCount + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type InStockCount { + product: Product! @provides(fields: "name") + quantity: Int! +} + +type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +union _Entity = Product + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_provides/test_provides_2.graphql b/tests/gql/test_provides/test_provides_2.graphql new file mode 100644 index 0000000..a18d895 --- /dev/null +++ b/tests/gql/test_provides/test_provides_2.graphql @@ -0,0 +1,28 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@external", "@key", "@provides"]) + +type Query { + inStockCount: InStockCount +} + +type InStockCount { + product: Product! @provides(fields: "name") + quantity: Int! +} + +type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_provides/test_provides_multiple_fields_1.graphql b/tests/gql/test_provides/test_provides_multiple_fields_1.graphql new file mode 100644 index 0000000..85dad3c --- /dev/null +++ b/tests/gql/test_provides/test_provides_multiple_fields_1.graphql @@ -0,0 +1,38 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@external", "@key", "@provides"]) + +type Query { + inStockCount: InStockCount + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type InStockCount { + product: Product! @provides(fields: "name weight") + quantity: Int! +} + +type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +union _Entity = Product + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_provides/test_provides_multiple_fields_2.graphql b/tests/gql/test_provides/test_provides_multiple_fields_2.graphql new file mode 100644 index 0000000..a5f1511 --- /dev/null +++ b/tests/gql/test_provides/test_provides_multiple_fields_2.graphql @@ -0,0 +1,28 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@external", "@key", "@provides"]) + +type Query { + inStockCount: InStockCount +} + +type InStockCount { + product: Product! @provides(fields: "name weight") + quantity: Int! +} + +type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_provides/test_provides_multiple_fields_as_list_1.graphql b/tests/gql/test_provides/test_provides_multiple_fields_as_list_1.graphql new file mode 100644 index 0000000..83672e2 --- /dev/null +++ b/tests/gql/test_provides/test_provides_multiple_fields_as_list_1.graphql @@ -0,0 +1,38 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key", "@provides"]) + +type Query { + inStockCount: InStockCount + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type InStockCount { + product: Product! @provides(fields: "name weight") + quantity: Int! +} + +type Product @key(fields: "sku") @extends { + sku: String! @external + name: String @external + weight: Int @external +} + +union _Entity = Product + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_provides/test_provides_multiple_fields_as_list_2.graphql b/tests/gql/test_provides/test_provides_multiple_fields_as_list_2.graphql new file mode 100644 index 0000000..ff687af --- /dev/null +++ b/tests/gql/test_provides/test_provides_multiple_fields_as_list_2.graphql @@ -0,0 +1,28 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key", "@provides"]) + +type Query { + inStockCount: InStockCount +} + +type InStockCount { + product: Product! @provides(fields: "name weight") + quantity: Int! +} + +type Product @key(fields: "sku") @extends { + sku: String! @external + name: String @external + weight: Int @external +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_provides_v1/test_provides_1.graphql b/tests/gql/test_provides_v1/test_provides_1.graphql new file mode 100644 index 0000000..71d13ff --- /dev/null +++ b/tests/gql/test_provides_v1/test_provides_1.graphql @@ -0,0 +1,29 @@ +type Query { + inStockCount: InStockCount + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type InStockCount { + product: Product! @provides(fields: "name") + quantity: Int! +} + +type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +union _Entity = Product + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_provides_v1/test_provides_2.graphql b/tests/gql/test_provides_v1/test_provides_2.graphql new file mode 100644 index 0000000..f370561 --- /dev/null +++ b/tests/gql/test_provides_v1/test_provides_2.graphql @@ -0,0 +1,19 @@ +type Query { + inStockCount: InStockCount +} + +type InStockCount { + product: Product! @provides(fields: "name") + quantity: Int! +} + +type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_provides_v1/test_provides_multiple_fields_1.graphql b/tests/gql/test_provides_v1/test_provides_multiple_fields_1.graphql new file mode 100644 index 0000000..6551753 --- /dev/null +++ b/tests/gql/test_provides_v1/test_provides_multiple_fields_1.graphql @@ -0,0 +1,29 @@ +type Query { + inStockCount: InStockCount + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type InStockCount { + product: Product! @provides(fields: "name weight") + quantity: Int! +} + +type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +union _Entity = Product + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_provides_v1/test_provides_multiple_fields_2.graphql b/tests/gql/test_provides_v1/test_provides_multiple_fields_2.graphql new file mode 100644 index 0000000..8dcca67 --- /dev/null +++ b/tests/gql/test_provides_v1/test_provides_multiple_fields_2.graphql @@ -0,0 +1,19 @@ +type Query { + inStockCount: InStockCount +} + +type InStockCount { + product: Product! @provides(fields: "name weight") + quantity: Int! +} + +type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_provides_v1/test_provides_multiple_fields_as_list_1.graphql b/tests/gql/test_provides_v1/test_provides_multiple_fields_as_list_1.graphql new file mode 100644 index 0000000..e797c61 --- /dev/null +++ b/tests/gql/test_provides_v1/test_provides_multiple_fields_as_list_1.graphql @@ -0,0 +1,29 @@ +type Query { + inStockCount: InStockCount + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type InStockCount { + product: Product! @provides(fields: "name weight") + quantity: Int! +} + +type Product @key(fields: "sku") @extends { + sku: String! @external + name: String @external + weight: Int @external +} + +union _Entity = Product + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_provides_v1/test_provides_multiple_fields_as_list_2.graphql b/tests/gql/test_provides_v1/test_provides_multiple_fields_as_list_2.graphql new file mode 100644 index 0000000..6679188 --- /dev/null +++ b/tests/gql/test_provides_v1/test_provides_multiple_fields_as_list_2.graphql @@ -0,0 +1,19 @@ +type Query { + inStockCount: InStockCount +} + +type InStockCount { + product: Product! @provides(fields: "name weight") + quantity: Int! +} + +type Product @key(fields: "sku") @extends { + sku: String! @external + name: String @external + weight: Int @external +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_requires/test_requires_multiple_fields_1.graphql b/tests/gql/test_requires/test_requires_multiple_fields_1.graphql new file mode 100644 index 0000000..d408da1 --- /dev/null +++ b/tests/gql/test_requires/test_requires_multiple_fields_1.graphql @@ -0,0 +1,34 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key", "@requires"]) + +type Query { + product: Product + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Product @key(fields: "sku") @extends { + sku: ID @external + size: Int @external + weight: Int @external + shippingEstimate: String @requires(fields: "size weight") +} + +union _Entity = Product + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_requires/test_requires_multiple_fields_2.graphql b/tests/gql/test_requires/test_requires_multiple_fields_2.graphql new file mode 100644 index 0000000..7ff53e0 --- /dev/null +++ b/tests/gql/test_requires/test_requires_multiple_fields_2.graphql @@ -0,0 +1,24 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key", "@requires"]) + +type Query { + product: Product +} + +type Product @key(fields: "sku") @extends { + sku: ID @external + size: Int @external + weight: Int @external + shippingEstimate: String @requires(fields: "size weight") +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_requires/test_requires_multiple_fields_as_list_1.graphql b/tests/gql/test_requires/test_requires_multiple_fields_as_list_1.graphql new file mode 100644 index 0000000..d408da1 --- /dev/null +++ b/tests/gql/test_requires/test_requires_multiple_fields_as_list_1.graphql @@ -0,0 +1,34 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key", "@requires"]) + +type Query { + product: Product + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Product @key(fields: "sku") @extends { + sku: ID @external + size: Int @external + weight: Int @external + shippingEstimate: String @requires(fields: "size weight") +} + +union _Entity = Product + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_requires/test_requires_multiple_fields_as_list_2.graphql b/tests/gql/test_requires/test_requires_multiple_fields_as_list_2.graphql new file mode 100644 index 0000000..7ff53e0 --- /dev/null +++ b/tests/gql/test_requires/test_requires_multiple_fields_as_list_2.graphql @@ -0,0 +1,24 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key", "@requires"]) + +type Query { + product: Product +} + +type Product @key(fields: "sku") @extends { + sku: ID @external + size: Int @external + weight: Int @external + shippingEstimate: String @requires(fields: "size weight") +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_requires/test_requires_with_input_1.graphql b/tests/gql/test_requires/test_requires_with_input_1.graphql new file mode 100644 index 0000000..a898ef2 --- /dev/null +++ b/tests/gql/test_requires/test_requires_with_input_1.graphql @@ -0,0 +1,33 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key", "@requires"]) + +type Query { + acme: Acme + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Acme @key(fields: "id") @extends { + id: ID! @external + age: Int @external + foo(someInput: String ): String @requires(fields: "age") +} + +union _Entity = Acme + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_requires/test_requires_with_input_2.graphql b/tests/gql/test_requires/test_requires_with_input_2.graphql new file mode 100644 index 0000000..2f4d703 --- /dev/null +++ b/tests/gql/test_requires/test_requires_with_input_2.graphql @@ -0,0 +1,23 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key", "@requires"]) + +type Query { + acme: Acme +} + +type Acme @key(fields: "id") @extends { + id: ID! @external + age: Int @external + foo(someInput: String ): String @requires(fields: "age") +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_requires_v1/test_requires_multiple_fields_1.graphql b/tests/gql/test_requires_v1/test_requires_multiple_fields_1.graphql new file mode 100644 index 0000000..f438018 --- /dev/null +++ b/tests/gql/test_requires_v1/test_requires_multiple_fields_1.graphql @@ -0,0 +1,25 @@ +type Query { + product: Product + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Product @key(fields: "sku") @extends { + sku: ID @external + size: Int @external + weight: Int @external + shippingEstimate: String @requires(fields: "size weight") +} + +union _Entity = Product + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_requires_v1/test_requires_multiple_fields_2.graphql b/tests/gql/test_requires_v1/test_requires_multiple_fields_2.graphql new file mode 100644 index 0000000..74d207f --- /dev/null +++ b/tests/gql/test_requires_v1/test_requires_multiple_fields_2.graphql @@ -0,0 +1,15 @@ +type Query { + product: Product +} + +type Product @key(fields: "sku") @extends { + sku: ID @external + size: Int @external + weight: Int @external + shippingEstimate: String @requires(fields: "size weight") +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_requires_v1/test_requires_multiple_fields_as_list_1.graphql b/tests/gql/test_requires_v1/test_requires_multiple_fields_as_list_1.graphql new file mode 100644 index 0000000..f438018 --- /dev/null +++ b/tests/gql/test_requires_v1/test_requires_multiple_fields_as_list_1.graphql @@ -0,0 +1,25 @@ +type Query { + product: Product + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Product @key(fields: "sku") @extends { + sku: ID @external + size: Int @external + weight: Int @external + shippingEstimate: String @requires(fields: "size weight") +} + +union _Entity = Product + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_requires_v1/test_requires_multiple_fields_as_list_2.graphql b/tests/gql/test_requires_v1/test_requires_multiple_fields_as_list_2.graphql new file mode 100644 index 0000000..74d207f --- /dev/null +++ b/tests/gql/test_requires_v1/test_requires_multiple_fields_as_list_2.graphql @@ -0,0 +1,15 @@ +type Query { + product: Product +} + +type Product @key(fields: "sku") @extends { + sku: ID @external + size: Int @external + weight: Int @external + shippingEstimate: String @requires(fields: "size weight") +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_requires_v1/test_requires_with_input_1.graphql b/tests/gql/test_requires_v1/test_requires_with_input_1.graphql new file mode 100644 index 0000000..55377e1 --- /dev/null +++ b/tests/gql/test_requires_v1/test_requires_with_input_1.graphql @@ -0,0 +1,24 @@ +type Query { + acme: Acme + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type Acme @key(fields: "id") @extends { + id: ID! @external + age: Int @external + foo(someInput: String ): String @requires(fields: "age") +} + +union _Entity = Acme + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_requires_v1/test_requires_with_input_2.graphql b/tests/gql/test_requires_v1/test_requires_with_input_2.graphql new file mode 100644 index 0000000..d903ea2 --- /dev/null +++ b/tests/gql/test_requires_v1/test_requires_with_input_2.graphql @@ -0,0 +1,14 @@ +type Query { + acme: Acme +} + +type Acme @key(fields: "id") @extends { + id: ID! @external + age: Int @external + foo(someInput: String ): String @requires(fields: "age") +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_scalar/test_custom_scalar_1.graphql b/tests/gql/test_scalar/test_custom_scalar_1.graphql new file mode 100644 index 0000000..e1607ae --- /dev/null +++ b/tests/gql/test_scalar/test_custom_scalar_1.graphql @@ -0,0 +1,30 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@inaccessible", "@shareable"]) + +type TestScalar @shareable { + testShareableScalar(x: AddressScalar): String @shareable + testInaccessibleScalar(x: AddressScalar): String @inaccessible +} + +scalar AddressScalar + +type Query { + test(x: AddressScalar): String + test2: [AddressScalar]! + _service: _Service! +} + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_scalar/test_custom_scalar_2.graphql b/tests/gql/test_scalar/test_custom_scalar_2.graphql new file mode 100644 index 0000000..da23193 --- /dev/null +++ b/tests/gql/test_scalar/test_custom_scalar_2.graphql @@ -0,0 +1,25 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@inaccessible", "@shareable"]) + +type TestScalar @shareable { + testShareableScalar(x: AddressScalar): String @shareable + testInaccessibleScalar(x: AddressScalar): String @inaccessible +} + +scalar AddressScalar + +type Query { + test(x: AddressScalar): String + test2: [AddressScalar]! +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_schema_annotation/test_chat_schema_1.graphql b/tests/gql/test_schema_annotation/test_chat_schema_1.graphql new file mode 100644 index 0000000..c1ba222 --- /dev/null +++ b/tests/gql/test_schema_annotation/test_chat_schema_1.graphql @@ -0,0 +1,42 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key"]) + +schema { + query: ChatQuery +} + +type ChatQuery { + message(id: ID!): ChatMessage + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type ChatMessage { + id: ID! + text: String + userId: ID + user: ChatUser! +} + +type ChatUser @key(fields: "userId") @extends { + userId: ID! @external +} + +union _Entity = ChatUser + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_schema_annotation/test_chat_schema_2.graphql b/tests/gql/test_schema_annotation/test_chat_schema_2.graphql new file mode 100644 index 0000000..2ea0c63 --- /dev/null +++ b/tests/gql/test_schema_annotation/test_chat_schema_2.graphql @@ -0,0 +1,32 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@extends", "@external", "@key"]) + +schema { + query: ChatQuery +} + +type ChatQuery { + message(id: ID!): ChatMessage +} + +type ChatMessage { + id: ID! + text: String + userId: ID + user: ChatUser! +} + +type ChatUser @key(fields: "userId") @extends { + userId: ID! @external +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_schema_annotation/test_user_schema_1.graphql b/tests/gql/test_schema_annotation/test_user_schema_1.graphql new file mode 100644 index 0000000..3f80e75 --- /dev/null +++ b/tests/gql/test_schema_annotation/test_user_schema_1.graphql @@ -0,0 +1,37 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + +schema { + query: UserQuery +} + +type UserQuery { + user(userId: ID!): User + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type User @key(fields: "email") @key(fields: "userId") { + userId: ID! + email: String! + name: String +} + +union _Entity = User + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_schema_annotation/test_user_schema_2.graphql b/tests/gql/test_schema_annotation/test_user_schema_2.graphql new file mode 100644 index 0000000..e20f2cc --- /dev/null +++ b/tests/gql/test_schema_annotation/test_user_schema_2.graphql @@ -0,0 +1,27 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key"]) + +schema { + query: UserQuery +} + +type UserQuery { + user(userId: ID!): User +} + +type User @key(fields: "email") @key(fields: "userId") { + userId: ID! + email: String! + name: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_schema_annotation_v1/test_chat_schema_1.graphql b/tests/gql/test_schema_annotation_v1/test_chat_schema_1.graphql new file mode 100644 index 0000000..cc8a641 --- /dev/null +++ b/tests/gql/test_schema_annotation_v1/test_chat_schema_1.graphql @@ -0,0 +1,33 @@ +schema { + query: ChatQuery +} + +type ChatQuery { + message(id: ID!): ChatMessage + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type ChatMessage { + id: ID! + text: String + userId: ID + user: ChatUser! +} + +type ChatUser @key(fields: "userId") @extends { + userId: ID! @external +} + +union _Entity = ChatUser + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_schema_annotation_v1/test_chat_schema_2.graphql b/tests/gql/test_schema_annotation_v1/test_chat_schema_2.graphql new file mode 100644 index 0000000..f0c4d0d --- /dev/null +++ b/tests/gql/test_schema_annotation_v1/test_chat_schema_2.graphql @@ -0,0 +1,23 @@ +schema { + query: ChatQuery +} + +type ChatQuery { + message(id: ID!): ChatMessage +} + +type ChatMessage { + id: ID! + text: String + userId: ID + user: ChatUser! +} + +type ChatUser @key(fields: "userId") @extends { + userId: ID! @external +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_schema_annotation_v1/test_user_schema_1.graphql b/tests/gql/test_schema_annotation_v1/test_user_schema_1.graphql new file mode 100644 index 0000000..7531f14 --- /dev/null +++ b/tests/gql/test_schema_annotation_v1/test_user_schema_1.graphql @@ -0,0 +1,28 @@ +schema { + query: UserQuery +} + +type UserQuery { + user(userId: ID!): User + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +type User @key(fields: "email") @key(fields: "userId") { + userId: ID! + email: String! + name: String +} + +union _Entity = User + +scalar _Any + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_schema_annotation_v1/test_user_schema_2.graphql b/tests/gql/test_schema_annotation_v1/test_user_schema_2.graphql new file mode 100644 index 0000000..ffbcca5 --- /dev/null +++ b/tests/gql/test_schema_annotation_v1/test_user_schema_2.graphql @@ -0,0 +1,18 @@ +schema { + query: UserQuery +} + +type UserQuery { + user(userId: ID!): User +} + +type User @key(fields: "email") @key(fields: "userId") { + userId: ID! + email: String! + name: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar _FieldSet \ No newline at end of file diff --git a/tests/gql/test_shareable/test_shareable_1.graphql b/tests/gql/test_shareable/test_shareable_1.graphql new file mode 100644 index 0000000..417c965 --- /dev/null +++ b/tests/gql/test_shareable/test_shareable_1.graphql @@ -0,0 +1,27 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@shareable"]) + +type Position @shareable { + x: Int! + y: Int! @shareable +} + +type Query { + inStockCount: Int! + _service: _Service! +} + +type _Service { + sdl: String +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/gql/test_shareable/test_shareable_2.graphql b/tests/gql/test_shareable/test_shareable_2.graphql new file mode 100644 index 0000000..5e585f5 --- /dev/null +++ b/tests/gql/test_shareable/test_shareable_2.graphql @@ -0,0 +1,22 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@shareable"]) + +type Position @shareable { + x: Int! + y: Int! @shareable +} + +type Query { + inStockCount: Int! +} + +""" +A string-serialized scalar represents a set of fields that's passed to a federated directive, such as @key, @requires, or @provides +""" +scalar FieldSet + +"""This string-serialized scalar represents a JWT scope""" +scalar federation__Scope + +"""This string-serialized scalar represents an authorization policy.""" +scalar federation__Policy \ No newline at end of file diff --git a/tests/test_annotation_corner_cases.py b/tests/test_annotation_corner_cases.py new file mode 100644 index 0000000..276cae5 --- /dev/null +++ b/tests/test_annotation_corner_cases.py @@ -0,0 +1,151 @@ +from pathlib import Path + +from graphene import Field, ID, ObjectType, String + +from graphene_federation import ( + LATEST_VERSION, + build_schema, + extends, + external, + key, + requires, +) +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_similar_field_name(): + """ + Test annotation with fields that have similar names. + """ + + @extends + @key("id") + class ChatUser(ObjectType): + uid = ID() + identified = ID() + id = external(ID()) + i_d = ID() + ID = ID() + + class ChatMessage(ObjectType): + id = ID(required=True) + user = Field(ChatUser) + + class ChatQuery(ObjectType): + message = Field(ChatMessage, id=ID(required=True)) + + schema = build_schema(query=ChatQuery, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_camel_case_field_name(): + """ + Test annotation with fields that have camel cases or snake case. + """ + + @key("auto_camel") + @extends + class Camel(ObjectType): + auto_camel = external(String()) + forcedCamel = requires(String(), fields="auto_camel") + a_snake = String() + aCamel = String() + + class Query(ObjectType): + camel = Field(Camel) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_camel_case_field_name_without_auto_camelcase(): + """ + Test annotation with fields that have camel cases or snake case but with the auto_camelcase disabled. + """ + + @extends + class Camel(ObjectType): + auto_camel = external(String()) + forcedCamel = requires(String(), fields="auto_camel") + a_snake = String() + aCamel = String() + + class Query(ObjectType): + camel = Field(Camel) + + schema = build_schema( + query=Query, auto_camelcase=False, federation_version=LATEST_VERSION + ) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_annotated_field_also_used_in_filter(): + """ + Test that when a field also used in filter needs to get annotated, it really annotates only the field. + See issue https://github.com/preply/graphene-federation/issues/50 + """ + + @key("id") + class B(ObjectType): + id = ID() + + @extends + class A(ObjectType): + id = external(ID()) + b = Field(B, id=ID()) + + class Query(ObjectType): + a = Field(A) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_annotate_object_with_meta_name(): + @key("id") + class B(ObjectType): + class Meta: + name = "Potato" + + id = ID() + + @extends + class A(ObjectType): + class Meta: + name = "Banana" + + id = external(ID()) + b = Field(B, id=ID()) + + class Query(ObjectType): + a = Field(A) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) diff --git a/tests/test_annotation_corner_cases_v1.py b/tests/test_annotation_corner_cases_v1.py new file mode 100644 index 0000000..70c0e0f --- /dev/null +++ b/tests/test_annotation_corner_cases_v1.py @@ -0,0 +1,143 @@ +from pathlib import Path + +from graphene import Field, ID, ObjectType, String + +from graphene_federation import ( + FederationVersion, + build_schema, + extends, + external, + key, + requires, +) +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_similar_field_name(): + """ + Test annotation with fields that have similar names. + """ + + @extends + @key("id") + class ChatUser(ObjectType): + uid = ID() + identified = ID() + id = external(ID()) + i_d = ID() + ID = ID() + + class ChatMessage(ObjectType): + id = ID(required=True) + user = Field(ChatUser) + + class ChatQuery(ObjectType): + message = Field(ChatMessage, id=ID(required=True)) + + schema = build_schema( + query=ChatQuery, federation_version=FederationVersion.VERSION_1_0 + ) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_camel_case_field_name(): + """ + Test annotation with fields that have camel cases or snake case. + """ + + @key("auto_camel") + @extends + class Camel(ObjectType): + auto_camel = external(String()) + forcedCamel = requires(String(), fields="auto_camel") + a_snake = String() + aCamel = String() + + class Query(ObjectType): + camel = Field(Camel) + + schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_camel_case_field_name_without_auto_camelcase(): + """ + Test annotation with fields that have camel cases or snake case but with the auto_camelcase disabled. + """ + + @extends + class Camel(ObjectType): + auto_camel = external(String()) + forcedCamel = requires(String(), fields="auto_camel") + a_snake = String() + aCamel = String() + + class Query(ObjectType): + camel = Field(Camel) + + schema = build_schema( + query=Query, + auto_camelcase=False, + federation_version=FederationVersion.VERSION_1_0, + ) + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_annotated_field_also_used_in_filter(): + """ + Test that when a field also used in filter needs to get annotated, it really annotates only the field. + See issue https://github.com/preply/graphene-federation/issues/50 + """ + + @key("id") + class B(ObjectType): + id = ID() + + @extends + class A(ObjectType): + id = external(ID()) + b = Field(B, id=ID()) + + class Query(ObjectType): + a = Field(A) + + schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_annotate_object_with_meta_name(): + @key("id") + class B(ObjectType): + class Meta: + name = "Potato" + + id = ID() + + @extends + class A(ObjectType): + class Meta: + name = "Banana" + + id = external(ID()) + b = Field(B, id=ID()) + + class Query(ObjectType): + a = Field(A) + + schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) diff --git a/tests/test_custom_enum.py b/tests/test_custom_enum.py new file mode 100644 index 0000000..867b4cd --- /dev/null +++ b/tests/test_custom_enum.py @@ -0,0 +1,39 @@ +from pathlib import Path + +import graphene +from graphene import ObjectType + +from graphene_federation import LATEST_VERSION, build_schema +from graphene_federation import inaccessible, shareable +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_custom_enum(): + @inaccessible + class Episode(graphene.Enum): + NEWHOPE = 4 + EMPIRE = 5 + JEDI = 6 + + inaccessible(Episode.NEWHOPE) + + @shareable + class TestCustomEnum(graphene.ObjectType): + test_shareable_scalar = shareable(Episode()) + test_inaccessible_scalar = inaccessible(Episode()) + + class Query(ObjectType): + test = Episode() + test2 = graphene.List(TestCustomEnum, required=True) + + schema = build_schema( + query=Query, federation_version=LATEST_VERSION, types=(TestCustomEnum,) + ) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) diff --git a/tests/test_custom_field_names.py b/tests/test_custom_field_names.py new file mode 100644 index 0000000..86cf8d4 --- /dev/null +++ b/tests/test_custom_field_names.py @@ -0,0 +1,192 @@ +from pathlib import Path + +import pytest +from graphene import Field, ID, ObjectType, String + +from graphene_federation import build_schema, key, provides, requires +from tests.util import file_handlers + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_key_auto_camelcase_false(): + try: + + @key("identifier") + @key("valid_email") + class User(ObjectType): + identifier = ID() + email = String(name="valid_email") + + class Query(ObjectType): + user = Field(User) + + _schema = build_schema(query=Query, auto_camelcase=False) + except Exception as exc: + pytest.fail(f"Unexpected Error {exc}") + + +def test_key_auto_camelcase_true(): + with pytest.raises(ValueError) as err: + + @key("identifier") + @key("valid_email") + class User(ObjectType): + identifier = ID() + email = String(name="valid_email") + + class Query(ObjectType): + user = Field(User) + + _schema = build_schema(query=Query, auto_camelcase=True) + + assert str(err.value) == '@key, field "validEmail" does not exist on type "User"' + + +def test_key_auto_camelcase_with_auto_case_false(): + try: + + @key("identifier") + @key("valid_email", auto_case=False) + class User(ObjectType): + identifier = ID() + email = String(name="valid_email") + + class Query(ObjectType): + user = Field(User) + + _schema = build_schema(query=Query, auto_camelcase=True) + except Exception as exc: + pytest.fail(f"Unexpected Error {exc}") + + +def test_requires_auto_camelcase_false(): + try: + + class Employee(ObjectType): + identifier = ID() + email = String(name="corp_email") + + class User(ObjectType): + identifier = ID() + employee = Field(Employee) + email = requires(String(), fields="employee { corp_email }") + + class Query(ObjectType): + user = Field(User) + + _schema = build_schema(query=Query, auto_camelcase=False) + except Exception as exc: + pytest.fail(f"Unexpected Error {exc}") + + +def test_requires_auto_camelcase_true(): + with pytest.raises(ValueError) as err: + + class Employee(ObjectType): + identifier = ID() + email = String(name="corp_email") + + class User(ObjectType): + identifier = ID() + employee = Field(Employee) + email = requires(String(), fields="employee { corp_email }") + + class Query(ObjectType): + user = Field(User) + + _schema = build_schema(query=Query, auto_camelcase=True) + + assert ( + str(err.value) + == '@requires, field "corpEmail" does not exist on type "Employee"' + ) + + +def test_requires_auto_camelcase_with_auto_case_false(): + try: + + class Employee(ObjectType): + identifier = ID() + email = String(name="corp_email") + + class User(ObjectType): + identifier = ID() + employee = Field(Employee) + email = requires( + String(), fields="employee { corp_email }", auto_case=False + ) + + class Query(ObjectType): + user = Field(User) + + _schema = build_schema(query=Query, auto_camelcase=True) + + except Exception as exc: + pytest.fail(f"Unexpected Error {exc}") + + +def test_provides_auto_camelcase_false(): + try: + + class Employee(ObjectType): + identifier = ID() + email = String(name="corp_email") + + class User(ObjectType): + identifier = ID() + employee = Field(Employee) + email = String() + + class Query(ObjectType): + user = provides(Field(User), fields="employee { corp_email }") + + _schema = build_schema(query=Query, auto_camelcase=False) + except Exception as exc: + pytest.fail(f"Unexpected Error {exc}") + + +def test_provides_auto_camelcase_true(): + with pytest.raises(ValueError) as err: + + class Employee(ObjectType): + identifier = ID() + email = String(name="corp_email") + + class User(ObjectType): + identifier = ID() + employee = Field(Employee) + email = String() + + class Query(ObjectType): + user = provides(Field(User), fields="employee { corp_email }") + + _schema = build_schema(query=Query, auto_camelcase=True) + + assert ( + str(err.value) + == '@provides, field "corpEmail" does not exist on type "Employee"' + ) + + +def test_provides_auto_camelcase_with_auto_case_false(): + try: + + class Employee(ObjectType): + identifier = ID() + email = String(name="corp_email") + + class User(ObjectType): + identifier = ID() + employee = Field(Employee) + email = String() + + class Query(ObjectType): + user = provides( + Field(User), fields="employee { corp_email }", auto_case=False + ) + + _schema = build_schema(query=Query, auto_camelcase=True) + + except Exception as exc: + pytest.fail(f"Unexpected Error {exc}") diff --git a/tests/test_extends.py b/tests/test_extends.py new file mode 100644 index 0000000..43824c2 --- /dev/null +++ b/tests/test_extends.py @@ -0,0 +1,36 @@ +import pytest +from graphene import ID, ObjectType, String +from graphene_directives import DirectiveValidationError + +from graphene_federation import build_schema, extends, key + + +def test_extend_non_existing_field_failure(): + """ + Test that using the key decorator and providing a field that does not exist fails. + """ + with pytest.raises(ValueError) as err: + + @key("potato") + @extends + class A(ObjectType): + id = ID() + + build_schema(types=(A,)) + + assert str(err.value) == '@key, field "potato" does not exist on type "A"' + + +def test_multiple_extend_failure(): + """ + Test that the extend decorator can't be used more than once on a type. + """ + with pytest.raises(DirectiveValidationError) as err: + + @extends + @extends + class A(ObjectType): + id = ID() + potato = String() + + assert str(err.value) == "@extends is not repeatable, at: A" diff --git a/tests/test_inaccessible.py b/tests/test_inaccessible.py new file mode 100644 index 0000000..f3d485f --- /dev/null +++ b/tests/test_inaccessible.py @@ -0,0 +1,85 @@ +from pathlib import Path + +import graphene +from graphene import ObjectType + +from graphene_federation import LATEST_VERSION, build_schema, inaccessible +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_inaccessible_interface(): + @inaccessible + class ReviewInterface(graphene.Interface): + interfaced_body = graphene.String(required=True) + + @inaccessible + class Review(graphene.ObjectType): + class Meta: + interfaces = (ReviewInterface,) + + id = inaccessible(graphene.Int(required=True)) + body = graphene.String(required=True) + + class Query(ObjectType): + in_stock_count = graphene.Int(required=True) + + build_schema( + query=Query, federation_version=LATEST_VERSION, types=(ReviewInterface, Review) + ) + + +def test_inaccessible(): + @inaccessible + class Position(graphene.ObjectType): + x = graphene.Int(required=True) + y = inaccessible(graphene.Int(required=True)) + + class Query(ObjectType): + in_stock_count = graphene.Int(required=True) + + schema = build_schema( + query=Query, federation_version=LATEST_VERSION, types=(Position,) + ) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_inaccessible_union(): + @inaccessible + class Human(graphene.ObjectType): + name = graphene.String() + born_in = graphene.String() + + @inaccessible + class Droid(graphene.ObjectType): + name = inaccessible(graphene.String()) + primary_function = graphene.String() + + @inaccessible + class Starship(graphene.ObjectType): + name = graphene.String() + length = inaccessible(graphene.Int()) + + @inaccessible + class SearchResult(graphene.Union): + class Meta: + types = (Human, Droid, Starship) + + class Query(ObjectType): + in_stock_count = graphene.Int(required=True) + + schema = build_schema( + query=Query, federation_version=LATEST_VERSION, types=(SearchResult,) + ) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) diff --git a/tests/test_key.py b/tests/test_key.py new file mode 100644 index 0000000..affab19 --- /dev/null +++ b/tests/test_key.py @@ -0,0 +1,158 @@ +from pathlib import Path + +import pytest +from graphene import Field, ID, ObjectType, String + +from graphene_federation import LATEST_VERSION, build_schema, key +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_multiple_keys(): + @key("identifier") + @key("email") + class User(ObjectType): + identifier = ID() + email = String() + + class Query(ObjectType): + user = Field(User) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_key_non_existing_field_failure(): + """ + Test that using the key decorator and providing a field that does not exist fails. + """ + with pytest.raises(ValueError) as err: + + @key("potato") + class A(ObjectType): + id = ID() + + build_schema(types=(A,), federation_version=LATEST_VERSION) + + assert '@key, field "potato" does not exist on type "A"' == str(err.value) + + +def test_compound_primary_key(): + class Organization(ObjectType): + registration_number = ID() + + @key("id organization { registration_number }") + class User(ObjectType): + id = ID() + organization = Field(Organization) + + class Query(ObjectType): + user = Field(User) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_compound_primary_key_with_depth(): + class BusinessUnit(ObjectType): + id = ID() + name = String() + + class Organization(ObjectType): + registration_number = ID() + business_unit = Field(BusinessUnit) + + @key("id organization { business_unit {id name}}") + class User(ObjectType): + id = ID() + organization = Field(Organization) + + class Query(ObjectType): + user = Field(User) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_invalid_compound_primary_key_failures(): + class BusinessUnit(ObjectType): + id = ID() + name = String() + + class Organization(ObjectType): + registration_number = ID() + bu = Field(BusinessUnit) + + @key("id name organization { registration_number }") + class User(ObjectType): + id = ID() + organization = Field(Organization) + + class Query(ObjectType): + user = Field(User) + + with pytest.raises(ValueError) as err: + # Field name absent on User ObjectType + build_schema(query=Query, federation_version=LATEST_VERSION) + + assert '@key, field "name" does not exist on type "User"' == str(err.value) + + @key("id organization { name }") + class User(ObjectType): + id = ID() + organization = Field(Organization) + + class Query(ObjectType): + user = Field(User) + + with pytest.raises(ValueError) as err: + # Presence of invalid field in organization field key + build_schema(query=Query, federation_version=LATEST_VERSION) + + assert '@key, field "name" does not exist on type "Organization"' == str(err.value) + + @key("id organization { bu }") + class User(ObjectType): + id = ID() + organization = Field(Organization) + + class Query(ObjectType): + user = Field(User) + + with pytest.raises(ValueError) as err: + # Presence of BusinessUnit in the key without subselection + build_schema(query=Query, federation_version=LATEST_VERSION) + + assert '@key, type Organization, field "bu" needs sub selections.' == str(err.value) + + @key("id organization { bu {name { field }} }") + class User(ObjectType): + id = ID() + organization = Field(Organization) + + class Query(ObjectType): + user = Field(User) + + with pytest.raises(ValueError) as err: + # Presence of subselection for the scalar 'name' field + build_schema(query=Query, federation_version=LATEST_VERSION) + + assert '@key, type BusinessUnit, field "name" cannot have sub selections.' == str( + err.value + ) diff --git a/tests/test_key_v1.py b/tests/test_key_v1.py new file mode 100644 index 0000000..ca8363b --- /dev/null +++ b/tests/test_key_v1.py @@ -0,0 +1,43 @@ +from pathlib import Path + +import pytest +from graphene import Field, ID, ObjectType, String + +from graphene_federation import FederationVersion, build_schema, key +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_multiple_keys(): + @key("identifier") + @key("email") + class User(ObjectType): + identifier = ID() + email = String() + + class Query(ObjectType): + user = Field(User) + + schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_key_non_existing_field_failure(): + """ + Test that using the key decorator and providing a field that does not exist fails. + """ + with pytest.raises(ValueError) as err: + + @key("potato") + class A(ObjectType): + id = ID() + + _ = build_schema(types=(A,), federation_version=FederationVersion.VERSION_1_0) + + assert '@key, field "potato" does not exist on type "A"' == str(err.value) diff --git a/tests/test_override.py b/tests/test_override.py new file mode 100644 index 0000000..e16baf8 --- /dev/null +++ b/tests/test_override.py @@ -0,0 +1,48 @@ +from pathlib import Path + +import pytest +from graphene import Field, ID, ObjectType, String +from graphene import Int +from graphene_directives import DirectiveValidationError + +from graphene_federation import LATEST_VERSION, build_schema +from graphene_federation import override +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_chain_requires_failure(): + """ + Check that we can't nest call the override method on a field. + """ + with pytest.raises(DirectiveValidationError) as err: + + class A(ObjectType): + something = override( + override(String(), from_="subgraph-1"), from_="subgraph-2" + ) + + assert "@override is not repeatable" in str(err.value) + + +def test_override(): + """ + Check that requires can take more than one field as input. + """ + + class Product(ObjectType): + sku = override(ID(), from_="subgraph-1") + size = override(Int(), from_="subgraph-2") + weight = override(Int(), from_="subgraph-3", label="Test label") + + class Query(ObjectType): + product = Field(Product) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) diff --git a/tests/test_provides.py b/tests/test_provides.py new file mode 100644 index 0000000..7a23687 --- /dev/null +++ b/tests/test_provides.py @@ -0,0 +1,92 @@ +from pathlib import Path + +from graphene import Field, ObjectType, String +from graphene import Int + +from graphene_federation import LATEST_VERSION, build_schema, extends, key +from graphene_federation import external, provides +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_provides(): + """ + https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced + """ + + @key("sku") + class Product(ObjectType): + sku = external(String(required=True)) + name = external(String()) + weight = external(Int()) + + class InStockCount(ObjectType): + product = provides(Field(Product, required=True), fields="name") + quantity = Int(required=True) + + class Query(ObjectType): + in_stock_count = Field(InStockCount) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_provides_multiple_fields(): + """ + https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced + """ + + @key("sku") + class Product(ObjectType): + sku = external(String(required=True)) + name = external(String()) + weight = external(Int()) + + class InStockCount(ObjectType): + product = provides(Field(Product, required=True), fields="name weight") + quantity = Int(required=True) + + class Query(ObjectType): + in_stock_count = Field(InStockCount) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_provides_multiple_fields_as_list(): + """ + https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced + """ + + @key("sku") + @extends + class Product(ObjectType): + sku = external(String(required=True)) + name = external(String()) + weight = external(Int()) + + class InStockCount(ObjectType): + product = provides(Field(Product, required=True), fields=["name", "weight"]) + quantity = Int(required=True) + + class Query(ObjectType): + in_stock_count = Field(InStockCount) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) diff --git a/tests/test_provides_v1.py b/tests/test_provides_v1.py new file mode 100644 index 0000000..c58f8f5 --- /dev/null +++ b/tests/test_provides_v1.py @@ -0,0 +1,92 @@ +from pathlib import Path + +from graphene import Field, ObjectType, String +from graphene import Int + +from graphene_federation import FederationVersion, build_schema, extends, key +from graphene_federation import external, provides +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_provides(): + """ + https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced + """ + + @key("sku") + class Product(ObjectType): + sku = external(String(required=True)) + name = external(String()) + weight = external(Int()) + + class InStockCount(ObjectType): + product = provides(Field(Product, required=True), fields="name") + quantity = Int(required=True) + + class Query(ObjectType): + in_stock_count = Field(InStockCount) + + schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_provides_multiple_fields(): + """ + https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced + """ + + @key("sku") + class Product(ObjectType): + sku = external(String(required=True)) + name = external(String()) + weight = external(Int()) + + class InStockCount(ObjectType): + product = provides(Field(Product, required=True), fields="name weight") + quantity = Int(required=True) + + class Query(ObjectType): + in_stock_count = Field(InStockCount) + + schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_provides_multiple_fields_as_list(): + """ + https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced + """ + + @key("sku") + @extends + class Product(ObjectType): + sku = external(String(required=True)) + name = external(String()) + weight = external(Int()) + + class InStockCount(ObjectType): + product = provides(Field(Product, required=True), fields=["name", "weight"]) + quantity = Int(required=True) + + class Query(ObjectType): + in_stock_count = Field(InStockCount) + + schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) diff --git a/tests/test_requires.py b/tests/test_requires.py new file mode 100644 index 0000000..daa8190 --- /dev/null +++ b/tests/test_requires.py @@ -0,0 +1,99 @@ +from pathlib import Path + +import pytest +from graphene import Field, ID, ObjectType, String +from graphene import Int +from graphene_directives import DirectiveValidationError + +from graphene_federation import LATEST_VERSION, build_schema, key +from graphene_federation import extends, external, requires +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_chain_requires_failure(): + """ + Check that we can't nest call the requires method on a field. + """ + with pytest.raises(DirectiveValidationError) as err: + + class A(ObjectType): + id = external(ID()) + something = requires(requires(String(), fields="id"), fields="id3") + + assert "@requires is not repeatable" in str(err.value) + + +def test_requires_multiple_fields(): + """ + Check that requires can take more than one field as input. + """ + + @key("sku") + @extends + class Product(ObjectType): + sku = external(ID()) + size = external(Int()) + weight = external(Int()) + shipping_estimate = requires(String(), fields="size weight") + + class Query(ObjectType): + product = Field(Product) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_requires_multiple_fields_as_list(): + """ + Check that requires can take more than one field as input. + """ + + @key("sku") + @extends + class Product(ObjectType): + sku = external(ID()) + size = external(Int()) + weight = external(Int()) + shipping_estimate = requires(String(), fields=["size", "weight"]) + + class Query(ObjectType): + product = Field(Product) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_requires_with_input(): + """ + Test checking that the issue https://github.com/preply/graphene-federation/pull/47 is resolved. + """ + + @key("id") + @extends + class Acme(ObjectType): + id = external(ID(required=True)) + age = external(Int()) + foo = requires(Field(String, someInput=String()), fields="age") + + class Query(ObjectType): + acme = Field(Acme) + + schema = build_schema(query=Query, federation_version=LATEST_VERSION) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) diff --git a/tests/test_requires_v1.py b/tests/test_requires_v1.py new file mode 100644 index 0000000..2c1722e --- /dev/null +++ b/tests/test_requires_v1.py @@ -0,0 +1,99 @@ +from pathlib import Path + +import pytest +from graphene import Field, ID, ObjectType, String +from graphene import Int +from graphene_directives import DirectiveValidationError + +from graphene_federation import FederationVersion, build_schema, key +from graphene_federation import extends, external, requires +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_chain_requires_failure(): + """ + Check that we can't nest call the requires method on a field. + """ + with pytest.raises(DirectiveValidationError) as err: + + class A(ObjectType): + id = external(ID()) + something = requires(requires(String(), fields="id"), fields="id3") + + assert "@requires is not repeatable" in str(err.value) + + +def test_requires_multiple_fields(): + """ + Check that requires can take more than one field as input. + """ + + @key("sku") + @extends + class Product(ObjectType): + sku = external(ID()) + size = external(Int()) + weight = external(Int()) + shipping_estimate = requires(String(), fields="size weight") + + class Query(ObjectType): + product = Field(Product) + + schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_requires_multiple_fields_as_list(): + """ + Check that requires can take more than one field as input. + """ + + @key("sku") + @extends + class Product(ObjectType): + sku = external(ID()) + size = external(Int()) + weight = external(Int()) + shipping_estimate = requires(String(), fields=["size", "weight"]) + + class Query(ObjectType): + product = Field(Product) + + schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_requires_with_input(): + """ + Test checking that the issue https://github.com/preply/graphene-federation/pull/47 is resolved. + """ + + @key("id") + @extends + class Acme(ObjectType): + id = external(ID(required=True)) + age = external(Int()) + foo = requires(Field(String, someInput=String()), fields="age") + + class Query(ObjectType): + acme = Field(Acme) + + schema = build_schema(query=Query, federation_version=FederationVersion.VERSION_1_0) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) diff --git a/tests/test_scalar.py b/tests/test_scalar.py new file mode 100644 index 0000000..88df382 --- /dev/null +++ b/tests/test_scalar.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import Any + +import graphene +from graphene import ObjectType, String +from graphene import Scalar + +from graphene_federation import LATEST_VERSION, build_schema +from graphene_federation import inaccessible, shareable +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_custom_scalar(): + class AddressScalar(Scalar): + base = String + + @staticmethod + def coerce_address(value: Any): + ... + + serialize = coerce_address + parse_value = coerce_address + + @staticmethod + def parse_literal(ast): + ... + + @shareable + class TestScalar(graphene.ObjectType): + test_shareable_scalar = shareable(String(x=AddressScalar())) + test_inaccessible_scalar = inaccessible(String(x=AddressScalar())) + + class Query(ObjectType): + test = String(x=AddressScalar()) + test2 = graphene.List(AddressScalar, required=True) + + schema = build_schema( + query=Query, federation_version=LATEST_VERSION, types=(TestScalar,) + ) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) diff --git a/tests/test_schema_annotation.py b/tests/test_schema_annotation.py new file mode 100644 index 0000000..f487e90 --- /dev/null +++ b/tests/test_schema_annotation.py @@ -0,0 +1,132 @@ +from pathlib import Path + +from graphene import Field, ID, ObjectType, String +from graphene import NonNull +from graphql import graphql_sync + +from graphene_federation import LATEST_VERSION, build_schema, key +from graphene_federation import extends, external +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + +# ------------------------ +# User service +# ------------------------ +users = [ + {"user_id": "1", "name": "Jane", "email": "jane@mail.com"}, + {"user_id": "2", "name": "Jack", "email": "jack@mail.com"}, + {"user_id": "3", "name": "Mary", "email": "mary@mail.com"}, +] + + +@key("user_id") +@key("email") +class User(ObjectType): + user_id = ID(required=True) + email = String(required=True) + name = String() + + def __resolve_reference(self, info, *args, **kwargs): + if self.id: + user = next(filter(lambda x: x["id"] == self.id, users)) + elif self.email: + user = next(filter(lambda x: x["email"] == self.email, users)) + return User(**user) + + +class UserQuery(ObjectType): + user = Field(User, user_id=ID(required=True)) + + def resolve_user(self, info, user_id, *args, **kwargs): + return User(**next(filter(lambda x: x["user_id"] == user_id, users))) + + +user_schema = build_schema(query=UserQuery, federation_version=LATEST_VERSION) + +# ------------------------ +# Chat service +# ------------------------ +chat_messages = [ + {"id": "1", "user_id": "1", "text": "Hi"}, + {"id": "2", "user_id": "1", "text": "How is the weather?"}, + {"id": "3", "user_id": "2", "text": "Who are you"}, + {"id": "4", "user_id": "3", "text": "Don't be rude Jack"}, + {"id": "5", "user_id": "3", "text": "Hi Jane"}, + {"id": "6", "user_id": "2", "text": "Sorry but weather sucks so I am upset"}, +] + + +@extends +@key("user_id") +class ChatUser(ObjectType): + user_id = external(ID(required=True)) + + +class ChatMessage(ObjectType): + id = ID(required=True) + text = String() + user_id = ID() + user = NonNull(ChatUser) + + def resolve_user(self, info, *args, **kwargs): + return ChatUser(user_id=self.user_id) + + +class ChatQuery(ObjectType): + message = Field(ChatMessage, id=ID(required=True)) + + def resolve_message(self, info, id, *args, **kwargs): + return ChatMessage(**next(filter(lambda x: x["id"] == id, chat_messages))) + + +chat_schema = build_schema(query=ChatQuery, federation_version=LATEST_VERSION) + + +# ------------------------ +# Tests +# ------------------------ + + +def test_user_schema(): + """ + Check that the user schema has been annotated correctly + and that a request to retrieve a user works. + """ + + assert open_file("1") == str(user_schema) + assert open_file("2") == sdl_query(user_schema) + + query = """ + query { + user(userId: "2") { + name + } + } + """ + result = graphql_sync(user_schema.graphql_schema, query) + assert not result.errors + assert result.data == {"user": {"name": "Jack"}} + + +def test_chat_schema(): + """ + Check that the chat schema has been annotated correctly + and that a request to retrieve a chat message works. + """ + + assert open_file("1") == str(chat_schema) + assert open_file("2") == sdl_query(chat_schema) + + # Query the message field + query = """ + query { + message(id: "4") { + text + userId + } + } + """ + result = graphql_sync(chat_schema.graphql_schema, query) + assert not result.errors + assert result.data == {"message": {"text": "Don't be rude Jack", "userId": "3"}} diff --git a/graphene_federation/tests/test_schema_annotation_v1.py b/tests/test_schema_annotation_v1.py similarity index 51% rename from graphene_federation/tests/test_schema_annotation_v1.py rename to tests/test_schema_annotation_v1.py index 3ef2602..69caf55 100644 --- a/graphene_federation/tests/test_schema_annotation_v1.py +++ b/tests/test_schema_annotation_v1.py @@ -1,14 +1,14 @@ -from textwrap import dedent +from pathlib import Path +from graphene import Field, ID, ObjectType, String +from graphene import NonNull from graphql import graphql_sync -from graphene import ObjectType, ID, String, NonNull, Field +from graphene_federation import FederationVersion, build_schema, key +from graphene_federation import extends, external +from tests.util import file_handlers, sdl_query -from graphene_federation.entity import key -from graphene_federation.extend import extend -from graphene_federation.external import external -from graphene_federation.main import build_schema -from graphene_federation.utils import clean_schema +save_file, open_file = file_handlers(Path(__file__)) # ------------------------ # User service @@ -42,7 +42,9 @@ def resolve_user(self, info, user_id, *args, **kwargs): return User(**next(filter(lambda x: x["user_id"] == user_id, users))) -user_schema = build_schema(query=UserQuery) +user_schema = build_schema( + query=UserQuery, federation_version=FederationVersion.VERSION_1_0 +) # ------------------------ # Chat service @@ -57,7 +59,8 @@ def resolve_user(self, info, user_id, *args, **kwargs): ] -@extend("user_id") +@key("user_id") +@extends class ChatUser(ObjectType): user_id = external(ID(required=True)) @@ -79,7 +82,9 @@ def resolve_message(self, info, id, *args, **kwargs): return ChatMessage(**next(filter(lambda x: x["id"] == id, chat_messages))) -chat_schema = build_schema(query=ChatQuery) +chat_schema = build_schema( + query=ChatQuery, federation_version=FederationVersion.VERSION_1_0 +) # ------------------------ # Tests @@ -91,34 +96,9 @@ def test_user_schema(): Check that the user schema has been annotated correctly and that a request to retrieve a user works. """ - expected_result = dedent( - """ - schema { - query: UserQuery - } - - type UserQuery { - user(userId: ID!): User - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type User { - userId: ID! - email: String! - name: String - } - - union _Entity = User - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(user_schema) == clean_schema(expected_result) + assert open_file("1") == str(user_schema) + assert open_file("2") == sdl_query(user_schema) + query = """ query { user(userId: "2") { @@ -126,33 +106,10 @@ def test_user_schema(): } } """ + result = graphql_sync(user_schema.graphql_schema, query) assert not result.errors assert result.data == {"user": {"name": "Jack"}} - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(user_schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type UserQuery { - user(userId: ID!): User - } - - type User @key(fields: "email") @key(fields: "userId") { - userId: ID! - email: String! - name: String - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) def test_chat_schema(): @@ -160,39 +117,8 @@ def test_chat_schema(): Check that the chat schema has been annotated correctly and that a request to retrieve a chat message works. """ - expected_result = dedent( - """ - schema { - query: ChatQuery - } - - type ChatQuery { - message(id: ID!): ChatMessage - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! - } - - type ChatMessage { - id: ID! - text: String - userId: ID - user: ChatUser! - } - - type ChatUser { - userId: ID! - } - - union _Entity = ChatUser - - scalar _Any - - type _Service { - sdl: String - } - """ - ) - assert clean_schema(chat_schema) == clean_schema(expected_result) + assert open_file("1") == str(chat_schema) + assert open_file("2") == sdl_query(chat_schema) # Query the message field query = """ @@ -206,33 +132,3 @@ def test_chat_schema(): result = graphql_sync(chat_schema.graphql_schema, query) assert not result.errors assert result.data == {"message": {"text": "Don't be rude Jack", "userId": "3"}} - - # Check the federation service schema definition language - query = """ - query { - _service { - sdl - } - } - """ - result = graphql_sync(chat_schema.graphql_schema, query) - assert not result.errors - expected_result = dedent( - """ - type ChatQuery { - message(id: ID!): ChatMessage - } - - type ChatMessage { - id: ID! - text: String - userId: ID - user: ChatUser! - } - - extend type ChatUser @key(fields: "userId") { - userId: ID! @external - } - """ - ) - assert clean_schema(result.data["_service"]["sdl"]) == clean_schema(expected_result) diff --git a/tests/test_shareable.py b/tests/test_shareable.py new file mode 100644 index 0000000..4eaefa0 --- /dev/null +++ b/tests/test_shareable.py @@ -0,0 +1,92 @@ +from pathlib import Path + +import graphene +import pytest +from graphene import ObjectType +from graphene_directives import DirectiveValidationError + +from graphene_federation import LATEST_VERSION, build_schema +from graphene_federation import shareable +from tests.util import file_handlers, sdl_query + +save_file, open_file = file_handlers(Path(__file__)) + + +def test_shareable_interface_failures(): + with pytest.raises(DirectiveValidationError) as err: + + @shareable + class ReviewInterface(graphene.Interface): + interfaced_body = graphene.String(required=True) + + @shareable + class Review(graphene.ObjectType): + class Meta: + interfaces = (ReviewInterface,) + + id = shareable(graphene.Int(required=True)) + body = graphene.String(required=True) + + class Query(ObjectType): + in_stock_count = graphene.Int(required=True) + + build_schema( + query=Query, + federation_version=LATEST_VERSION, + types=(ReviewInterface, Review), + ) + + assert "@shareable cannot be used for ReviewInterface" in str(err.value) + + +def test_shareable(): + @shareable + class Position(graphene.ObjectType): + x = graphene.Int(required=True) + y = shareable(graphene.Int(required=True)) + + class Query(ObjectType): + in_stock_count = graphene.Int(required=True) + + schema = build_schema( + query=Query, federation_version=LATEST_VERSION, types=(Position,) + ) + + # save_file(str(schema), "1") + # save_file(sdl_query(schema), "2") + + assert open_file("1") == str(schema) + assert open_file("2") == sdl_query(schema) + + +def test_shareable_union(): + with pytest.raises(DirectiveValidationError) as err: + + @shareable + class Human(graphene.ObjectType): + name = graphene.String() + born_in = graphene.String() + + @shareable + class Droid(graphene.ObjectType): + name = shareable(graphene.String()) + primary_function = graphene.String() + + @shareable + class Starship(graphene.ObjectType): + name = graphene.String() + length = shareable(graphene.Int()) + + @shareable + class SearchResult(graphene.Union): + class Meta: + types = (Human, Droid, Starship) + + class Query(ObjectType): + in_stock_count = graphene.Int(required=True) + + _ = build_schema( + query=Query, federation_version=LATEST_VERSION, types=(SearchResult,) + ) + + assert "@shareable cannot be used for SearchResult" in str(err.value) diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 0000000..111e80f --- /dev/null +++ b/tests/util.py @@ -0,0 +1,42 @@ +import inspect +import os +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from graphql import graphql_sync + + +def file_handlers( + path: Path, +) -> tuple[Callable[[Any, str], None], Callable[[str], str]]: + curr_dir = path.parent + file_name = path.name.replace(".py", "") + + try: + os.mkdir(f"{curr_dir}/gql/{file_name}") + except FileExistsError: + pass + + def save_file(data, extra_path: str = ""): + function_name = inspect.stack()[1].function + with open( + f"{curr_dir}/gql/{file_name}/{function_name}_{extra_path}.graphql", "w" + ) as f: + f.write(str(data)) + + def open_file(extra_path: str = ""): + function_name = inspect.stack()[1].function + with open( + f"{curr_dir}/gql/{file_name}/{function_name}_{extra_path}.graphql", "r" + ) as f: + return f.read() + + return save_file, open_file + + +def sdl_query(schema) -> str: + query = "query { _service { sdl } }" + result = graphql_sync(schema.graphql_schema, query) + assert not result.errors + return result.data["_service"]["sdl"]