diff --git a/README.md b/README.md index 67ef580f..8139156d 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,15 @@ Optional settings: - `plugins` (defaults to `[]`) - list of plugins to use during generation - `enable_custom_operations` (defaults to `false`) - enables building custom operations. Generates additional files that contains all the classes and methods for generation. +These options control which fields are included in the GraphQL introspection query when using `remote_schema_url`. + +- `introspection_descriptions` (defaults to `false`) – include descriptions in the introspection result +- `introspection_input_value_deprecation` (defaults to `false`) – include deprecation information for input values +- `introspection_specified_by_url` (defaults to `false`) – include `specifiedByUrl` for custom scalars +- `introspection_schema_description` (defaults to `false`) – include schema description +- `introspection_directive_is_repeatable` (defaults to `false`) – include `isRepeatable` information for directives +- `introspection_input_object_one_of` (defaults to `false`) – include `oneOf` information for input objects + ## Custom operation builder The custom operation builder allows you to create complex GraphQL queries in a structured and intuitive way. @@ -178,7 +187,7 @@ Ariadne Codegen ships with optional plugins importable from the `ariadne_codegen [tool.ariadne-codegen] ... plugins = ["ariadne_codegen.contrib.extract_operations.ExtractOperationsPlugin"] - + [tool.ariadne-codegen.extract_operations] operations_module_name = "custom_operations_module_name" ``` diff --git a/ariadne_codegen/main.py b/ariadne_codegen/main.py index ae32da1a..59df36be 100644 --- a/ariadne_codegen/main.py +++ b/ariadne_codegen/main.py @@ -51,6 +51,7 @@ def client(config_dict): headers=settings.remote_schema_headers, verify_ssl=settings.remote_schema_verify_ssl, timeout=settings.remote_schema_timeout, + introspection_settings=settings.introspection_settings, ) plugin_manager = PluginManager( @@ -95,6 +96,7 @@ def graphql_schema(config_dict): headers=settings.remote_schema_headers, verify_ssl=settings.remote_schema_verify_ssl, timeout=settings.remote_schema_timeout, + introspection_settings=settings.introspection_settings, ) ) plugin_manager = PluginManager( diff --git a/ariadne_codegen/schema.py b/ariadne_codegen/schema.py index 53cf28ab..f5ed291c 100644 --- a/ariadne_codegen/schema.py +++ b/ariadne_codegen/schema.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from dataclasses import asdict from pathlib import Path from typing import Optional, cast @@ -29,6 +30,7 @@ InvalidGraphqlSyntax, InvalidOperationForSchema, ) +from .settings import IntrospectionSettings def filter_operations_definitions( @@ -68,10 +70,15 @@ def get_graphql_schema_from_url( headers: Optional[dict[str, str]] = None, verify_ssl: bool = True, timeout: float = 5, + introspection_settings: Optional[IntrospectionSettings] = None, ) -> GraphQLSchema: return build_client_schema( introspect_remote_schema( - url=url, headers=headers, verify_ssl=verify_ssl, timeout=timeout + url=url, + headers=headers, + verify_ssl=verify_ssl, + timeout=timeout, + introspection_settings=introspection_settings, ), assume_valid=True, ) @@ -82,11 +89,15 @@ def introspect_remote_schema( headers: Optional[dict[str, str]] = None, verify_ssl: bool = True, timeout: float = 5, + introspection_settings: Optional[IntrospectionSettings] = None, ) -> IntrospectionQuery: + # If introspection settings are not provided, use default values. + settings = introspection_settings or IntrospectionSettings() + query = get_introspection_query(**asdict(settings)) try: response = httpx.post( url, - json={"query": get_introspection_query(descriptions=False)}, + json={"query": query}, headers=headers, verify=verify_ssl, timeout=timeout, @@ -105,14 +116,14 @@ def introspect_remote_schema( except ValueError as exc: raise IntrospectionError("Introspection result is not a valid json.") from exc - if (not isinstance(response_json, dict)) or ("data" not in response_json): + if not isinstance(response_json, dict): raise IntrospectionError("Invalid introspection result format.") errors = response_json.get("errors") if errors: raise IntrospectionError(f"Introspection errors: {errors}") - data = response_json["data"] + data = response_json.get("data") if not isinstance(data, dict): raise IntrospectionError("Invalid data key in introspection result.") diff --git a/ariadne_codegen/settings.py b/ariadne_codegen/settings.py index 74d29e17..d87877b6 100644 --- a/ariadne_codegen/settings.py +++ b/ariadne_codegen/settings.py @@ -1,7 +1,7 @@ import enum import os import re -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from keyword import iskeyword from pathlib import Path from textwrap import dedent @@ -31,6 +31,21 @@ class Strategy(str, enum.Enum): GRAPHQL_SCHEMA = "graphqlschema" +@dataclass +class IntrospectionSettings: + """ + Introspection settings for schema generation. + """ + + descriptions: bool = False + input_value_deprecation: bool = False + specified_by_url: bool = False + schema_description: bool = False + directive_is_repeatable: bool = False + # graphql-core will rename this to one_of in a future version (update when bumping) + input_object_one_of: bool = False + + @dataclass class BaseSettings: schema_path: str = "" @@ -40,6 +55,12 @@ class BaseSettings: remote_schema_timeout: float = 5 enable_custom_operations: bool = False plugins: list[str] = field(default_factory=list) + introspection_descriptions: bool = False + introspection_input_value_deprecation: bool = False + introspection_specified_by_url: bool = False + introspection_schema_description: bool = False + introspection_directive_is_repeatable: bool = False + introspection_input_object_one_of: bool = False def __post_init__(self): if not self.schema_path and not self.remote_schema_url: @@ -54,6 +75,37 @@ def __post_init__(self): if self.remote_schema_url: self.remote_schema_url = resolve_env_vars_in_string(self.remote_schema_url) + @property + def using_remote_schema(self) -> bool: + """ + Return true if remote schema is used as source, false otherwise. + """ + return bool(self.remote_schema_url) and not bool(self.schema_path) + + @property + def introspection_settings(self) -> IntrospectionSettings: + """ + Return ``IntrospectionSettings`` instance build from provided configuration. + """ + return IntrospectionSettings( + descriptions=self.introspection_descriptions, + input_value_deprecation=self.introspection_input_value_deprecation, + specified_by_url=self.introspection_specified_by_url, + schema_description=self.introspection_schema_description, + directive_is_repeatable=self.introspection_directive_is_repeatable, + input_object_one_of=self.introspection_input_object_one_of, + ) + + def _introspection_settings_message(self) -> str: + """ + Return human readable message with introspection settings values. + """ + formatted = ", ".join( + f"{key}={str(value).lower()}" + for key, value in asdict(self.introspection_settings).items() + ) + return f"Introspection settings: {formatted}" + @dataclass class ClientSettings(BaseSettings): @@ -177,10 +229,14 @@ def used_settings_message(self) -> str: if self.include_typename else "Not including __typename fields in generated queries." ) + introspection_msg = ( + self._introspection_settings_message() if self.using_remote_schema else "" + ) return dedent( f"""\ Selected strategy: {Strategy.CLIENT} Using schema from '{self.schema_path or self.remote_schema_url}'. + {introspection_msg} Reading queries from '{self.queries_path}'. Using '{self.target_package_name}' as package name. Generating package into '{self.target_package_path}'. @@ -221,12 +277,16 @@ def used_settings_message(self): if self.plugins else "No plugin is being used." ) + introspection_msg = ( + self._introspection_settings_message() if self.using_remote_schema else "" + ) if self.target_file_format == "py": return dedent( f"""\ Selected strategy: {Strategy.GRAPHQL_SCHEMA} Using schema from {self.schema_path or self.remote_schema_url} + {introspection_msg} Saving graphql schema to: {self.target_file_path} Using {self.schema_variable_name} as variable name for schema. Using {self.type_map_variable_name} as variable name for type map. @@ -238,6 +298,7 @@ def used_settings_message(self): f"""\ Selected strategy: {Strategy.GRAPHQL_SCHEMA} Using schema from {self.schema_path or self.remote_schema_url} + {introspection_msg} Saving graphql schema to: {self.target_file_path} {plugins_msg} """ diff --git a/tests/test_schema.py b/tests/test_schema.py index 8046befa..7a0b3973 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -17,6 +17,7 @@ read_graphql_file, walk_graphql_files, ) +from ariadne_codegen.settings import IntrospectionSettings @pytest.fixture @@ -298,9 +299,11 @@ def test_introspect_remote_schema_raises_introspection_error_for_not_dict_respon return_value=httpx.Response(status_code=200, content="[]"), ) - with pytest.raises(IntrospectionError): + with pytest.raises(IntrospectionError) as exc: introspect_remote_schema("http://testserver/graphql/") + assert "Invalid introspection result format." in str(exc.value) + def test_introspect_remote_schema_raises_introspection_error_for_json_without_data_key( mocker, @@ -310,9 +313,11 @@ def test_introspect_remote_schema_raises_introspection_error_for_json_without_da return_value=httpx.Response(status_code=200, content='{"not_data": null}'), ) - with pytest.raises(IntrospectionError): + with pytest.raises(IntrospectionError) as exc: introspect_remote_schema("http://testserver/graphql/") + assert "Invalid data key in introspection result." in str(exc.value) + def test_introspect_remote_schema_raises_introspection_error_for_graphql_errors(mocker): mocker.patch( @@ -349,9 +354,11 @@ def test_introspect_remote_schema_raises_introspection_error_for_invalid_data_va ), ) - with pytest.raises(IntrospectionError): + with pytest.raises(IntrospectionError) as exc: introspect_remote_schema("http://testserver/graphql/") + assert "Invalid data key in introspection result." in str(exc.value) + def test_introspect_remote_schema_returns_introspection_result(mocker): mocker.patch( @@ -440,3 +447,74 @@ def test_get_graphql_queries_with_invalid_query_for_schema_raises_invalid_operat get_graphql_queries( invalid_query_for_schema_file.as_posix(), build_schema(schema_str) ) + + +def test_introspect_remote_schema_passes_introspection_settings_to_introspection_query( + mocker, +): + """ + Test that the introspection settings are passed to the get_introspection_query + function when introspecting the remote schema. + """ + mocked_get_query = mocker.patch( + "ariadne_codegen.schema.get_introspection_query", + return_value="query { __schema { queryType { name } } }", + ) + mocker.patch( + "ariadne_codegen.schema.httpx.post", + return_value=httpx.Response( + status_code=200, content='{"data": {"__schema": {}}}' + ), + ) + + settings = IntrospectionSettings( + descriptions=True, + input_value_deprecation=True, + specified_by_url=True, + schema_description=True, + directive_is_repeatable=True, + input_object_one_of=True, + ) + + introspect_remote_schema( + "http://testserver/graphql/", introspection_settings=settings + ) + + mocked_get_query.assert_called_once_with( + descriptions=True, + specified_by_url=True, + directive_is_repeatable=True, + schema_description=True, + input_value_deprecation=True, + input_object_one_of=True, + ) + + +def test_introspect_remote_schema_uses_default_introspection_settings_when_not_provided( + mocker, +): + """ + Test that when introspection settings are not provided, the default values are used + in the get_introspection_query call. + """ + mocked_get_query = mocker.patch( + "ariadne_codegen.schema.get_introspection_query", + return_value="query { __schema { queryType { name } } }", + ) + mocker.patch( + "ariadne_codegen.schema.httpx.post", + return_value=httpx.Response( + status_code=200, content='{"data": {"__schema": {}}}' + ), + ) + + introspect_remote_schema("http://testserver/graphql/") + + mocked_get_query.assert_called_once_with( + descriptions=False, + specified_by_url=False, + directive_is_repeatable=False, + schema_description=False, + input_value_deprecation=False, + input_object_one_of=False, + ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 047bd1e1..5f36d347 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -479,3 +479,190 @@ def test_client_settings_include_typename_can_be_set_to_true(tmp_path): ) assert settings.include_typename is True + + +def test_using_remote_schema_true_when_only_remote_schema_url_is_provided(tmp_path): + """ + Test that using_remote_schema is True when only remote_schema_url is provided. + """ + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + settings = ClientSettings( + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + ) + + assert settings.using_remote_schema is True + + +def test_using_remote_schema_false_when_only_schema_path_is_provided(tmp_path): + """ + Test that using_remote_schema is False when only schema_path is provided. + """ + schema_path = tmp_path / "schema.graphql" + schema_path.touch() + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + base_client_file_content = """ + class BaseClient: + pass + """ + base_client_file_path = tmp_path / "base_client.py" + base_client_file_path.write_text(dedent(base_client_file_content)) + + settings = ClientSettings( + schema_path=schema_path.as_posix(), + queries_path=queries_path.as_posix(), + base_client_name="BaseClient", + base_client_file_path=base_client_file_path.as_posix(), + ) + + assert settings.using_remote_schema is False + + +def test_using_remote_schema_false_when_both_provided(tmp_path): + """ + Test that using_remote_schema is False when both schema_path and remote_schema_url + are provided. + """ + schema_path = tmp_path / "schema.graphql" + schema_path.touch() + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + base_client_file_content = """ + class BaseClient: + pass + """ + base_client_file_path = tmp_path / "base_client.py" + base_client_file_path.write_text(dedent(base_client_file_content)) + + settings = ClientSettings( + schema_path=schema_path.as_posix(), + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + base_client_name="BaseClient", + base_client_file_path=base_client_file_path.as_posix(), + ) + + assert settings.using_remote_schema is False + + +def test_introspection_settings_defaults(tmp_path): + """ + Test that introspection settings have the correct default values. + """ + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + settings = ClientSettings( + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + ) + + opts = settings.introspection_settings + assert opts.descriptions is False + assert opts.input_value_deprecation is False + assert opts.specified_by_url is False + assert opts.schema_description is False + assert opts.directive_is_repeatable is False + assert opts.input_object_one_of is False + + +def test_introspection_settings_overrides_are_mapped(tmp_path): + """ + Test that introspection settings overrides are correctly mapped. + """ + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + settings = ClientSettings( + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + introspection_descriptions=True, + introspection_input_value_deprecation=True, + introspection_specified_by_url=True, + introspection_schema_description=True, + introspection_directive_is_repeatable=True, + introspection_input_object_one_of=True, + ) + + opts = settings.introspection_settings + assert opts.descriptions is True + assert opts.input_value_deprecation is True + assert opts.specified_by_url is True + assert opts.schema_description is True + assert opts.directive_is_repeatable is True + assert opts.input_object_one_of is True + + +def test_client_settings_used_settings_message_includes_introspection( + tmp_path, +): + """ + Test that used_settings_message includes introspection settings when remote schema + is used. + """ + queries_path = tmp_path / "queries.graphql" + queries_path.touch() + + remote_settings = ClientSettings( + remote_schema_url="http://testserver/graphql/", + queries_path=queries_path.as_posix(), + introspection_schema_description=True, + ) + assert "Introspection settings:" in remote_settings.used_settings_message + assert "schema_description=true" in remote_settings.used_settings_message + + schema_path = tmp_path / "schema.graphql" + schema_path.touch() + + base_client_file_content = """ + class BaseClient: + pass + """ + base_client_file_path = tmp_path / "base_client.py" + base_client_file_path.write_text(dedent(base_client_file_content)) + + local_settings = ClientSettings( + schema_path=schema_path.as_posix(), + queries_path=queries_path.as_posix(), + base_client_name="BaseClient", + base_client_file_path=base_client_file_path.as_posix(), + introspection_schema_description=True, + ) + assert "Introspection settings:" not in local_settings.used_settings_message + + +def test_graphql_schema_settings_used_settings_message_includes_introspection( + tmp_path, +): + """ + Test that used_settings_message includes introspection settings when remote schema + is used. + """ + remote_settings = GraphQLSchemaSettings( + remote_schema_url="http://testserver/graphql/", + introspection_specified_by_url=True, + ) + assert "Introspection settings:" in remote_settings.used_settings_message + assert "specified_by_url=true" in remote_settings.used_settings_message + assert "descriptions=false" in remote_settings.used_settings_message + + schema_path = tmp_path / "schema.graphql" + schema_path.touch() + + local_settings = GraphQLSchemaSettings( + schema_path=schema_path.as_posix(), + introspection_specified_by_url=True, + ) + assert "Introspection settings:" not in local_settings.used_settings_message + + both_settings = GraphQLSchemaSettings( + schema_path=schema_path.as_posix(), + remote_schema_url="http://testserver/graphql/", + introspection_specified_by_url=True, + ) + assert "Introspection settings:" not in both_settings.used_settings_message