diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000..4462f97824 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,11 @@ +Release type: patch + +Added option for the export_schema command to override the output federation version. +usage example: strawberry export-schema --federation-version=2.5 + +This change ONLY affects "strawberry export-schema" command and is fully backward-compatible without any logic change. + +Warning: Please use with caution!! + +If the schema define directives that are not supported by the specified version (in the override parameter) the schema +will still generate the output using the value from the override, and may break at runtime. diff --git a/docs/guides/schema-export.md b/docs/guides/schema-export.md index 96e04f34eb..6e03c92576 100644 --- a/docs/guides/schema-export.md +++ b/docs/guides/schema-export.md @@ -32,3 +32,15 @@ Alternatively, the `--output` option can be used: ```bash strawberry export-schema package.module:schema --output schema.graphql ``` + +You can override the output directive to have specific federation schema (e.g: +schema @link(url: "https://specs.apollo.dev/federation/v2.5"): + +```bash +strawberry export-schema package.module:schema --federation-version=2.5 --output schema.graphql +``` + +> [!WARNING] \ +> If the schema define directives that are not supported by the specified +> version (in the override parameter) the schema will still generate the output +> using the value from the override, and may break at runtime. diff --git a/strawberry/cli/commands/export_schema.py b/strawberry/cli/commands/export_schema.py index 9592f5a488..1178a1f809 100644 --- a/strawberry/cli/commands/export_schema.py +++ b/strawberry/cli/commands/export_schema.py @@ -26,7 +26,20 @@ def export_schema( "-o", help="File to save the exported schema. If not provided, prints to console.", ), + federation_version: float = typer.Option( + None, + "--federation-version", + "-e", + help=( + "Override the output federation schema version. please use with care!" + "schema may break if it have directives that are not supported by the defined federation version." + "(for directive version compatibility please see: https://www.apollographql.com/docs/graphos/reference/federation/directives)" + ), + min=1, + ), ) -> None: + if federation_version: + app.__setattr__("federation_version_override", federation_version) schema_symbol = load_schema(schema, app_dir) schema_text = print_schema(schema_symbol) diff --git a/strawberry/federation/schema_directives.py b/strawberry/federation/schema_directives.py index 0f9232d7f3..977ded8bcc 100644 --- a/strawberry/federation/schema_directives.py +++ b/strawberry/federation/schema_directives.py @@ -1,7 +1,9 @@ +import typing from dataclasses import dataclass from typing import ClassVar, List, Optional from strawberry import directive_field +from strawberry.cli import app from strawberry.schema_directive import Location, schema_directive from strawberry.types.unset import UNSET @@ -11,12 +13,30 @@ LinkPurpose, ) +FEDERATION_VERSION_BASE_URL = "https://specs.apollo.dev/federation/v" + @dataclass class ImportedFrom: name: str url: str = "https://specs.apollo.dev/federation/v2.7" + def __init__(self, **kwargs: typing.Dict[str, typing.Any]) -> None: + if hasattr(self, "__dataclass_fields__"): + args = self.__dataclass_fields__ + for key, value in kwargs.items(): + if key in args and type(value) is args.get(key).type: + self.__setattr__(key, value) + if ( + hasattr(app, "federation_version_override") + and type(value) is str + and str(value).startswith("https://specs.apollo.dev/federation/") + ): + self.__setattr__( + key, + f"{FEDERATION_VERSION_BASE_URL}{app.federation_version_override!s}", + ) + class FederationDirective: imported_from: ClassVar[ImportedFrom] diff --git a/tests/cli/test_export_schema.py b/tests/cli/test_export_schema.py index 3f1b342b74..a2eb515a85 100644 --- a/tests/cli/test_export_schema.py +++ b/tests/cli/test_export_schema.py @@ -1,7 +1,25 @@ +import typing + +from click.testing import Result from typer import Typer from typer.testing import CliRunner +def check_generated_schema( + cli_app: Typer, + cli_runner: CliRunner, + schema_override: typing.Optional[float] = None, +) -> Result: + selector = "tests.fixtures.sample_package.sample_module_federated:schema" + args = [ + "export-schema", + selector, + ] + if schema_override: + args.append(f"--federation-version={schema_override!s}") + return cli_runner.invoke(cli_app, args) + + def test_schema_export(cli_app: Typer, cli_runner: CliRunner): selector = "tests.fixtures.sample_package.sample_module:schema" result = cli_runner.invoke(cli_app, ["export-schema", selector]) @@ -19,6 +37,33 @@ def test_schema_export(cli_app: Typer, cli_runner: CliRunner): ) +def test_schema_export_with_federation_version_override( + cli_app: Typer, cli_runner: CliRunner +): + result = check_generated_schema(cli_app, cli_runner, 2.5) + assert result.exit_code == 0 + assert result.stdout.startswith( + 'schema @link(url: "https://specs.apollo.dev/federation/v2.5"' + ) + + +def test_schema_export_without_federation_version_override( + cli_app: Typer, cli_runner: CliRunner +): + result = check_generated_schema(cli_app, cli_runner) + assert result.exit_code == 0 + assert result.stdout.startswith( + 'schema @link(url: "https://specs.apollo.dev/federation/v2.7"' + ) + + +def test_invalid_schema_export_federation_version_override( + cli_app: Typer, cli_runner: CliRunner +): + result = check_generated_schema(cli_app, cli_runner, 0.2) + assert result.exit_code == 2 + + def test_default_schema_symbol_name(cli_app: Typer, cli_runner: CliRunner): selector = "tests.fixtures.sample_package.sample_module" result = cli_runner.invoke(cli_app, ["export-schema", selector]) diff --git a/tests/fixtures/sample_package/sample_module_federated.py b/tests/fixtures/sample_package/sample_module_federated.py new file mode 100644 index 0000000000..cd7f7a9e87 --- /dev/null +++ b/tests/fixtures/sample_package/sample_module_federated.py @@ -0,0 +1,65 @@ +import typing + +import strawberry +from strawberry.federation.schema_directives import Key + + +@strawberry.federation.type(directives=[Key(fields="id", resolvable=False)]) +class User: + id: strawberry.ID + + +async def get_user(root: "Organization", info: strawberry.Info) -> User: + return User(id=root._owner) + + +@strawberry.federation.type(keys=["id"]) +class Organization: + id: strawberry.ID + name: str + _owner: strawberry.Private[User] + owner: User = strawberry.field(resolver=get_user) + + @classmethod + async def resolve_reference( + cls, id: strawberry.ID + ) -> typing.Optional["Organization"]: + return await get_organization_by_id(id=id) + + +async def get_organizations(): + return [ + Organization( + id=strawberry.ID("org1"), + name="iotflow", + _owner="abcdert1", + ), + Organization( + id=strawberry.ID("org2"), + name="ibrag", + _owner="abdfr2", + ), + ] + + +async def get_organization_by_id(id: strawberry.ID) -> typing.Optional[Organization]: + for organization in await get_organizations(): + if organization.id == id: + return organization + return None + + +@strawberry.type +class Query: + organizations: typing.List[Organization] = strawberry.field( + resolver=get_organizations + ) + + @strawberry.field + async def organization(self, id: strawberry.ID) -> typing.Optional[Organization]: + return await get_organization_by_id(id) + + +schema = strawberry.federation.Schema( + query=Query, types=[Organization, User], enable_federation_2=True +)