diff --git a/CHANGELOG.md b/CHANGELOG.md index 49ce3a35..d3bec3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## 0.12.0 (UNRELEASED) + +- Added support to `graphqlschema` for saving schema as a GraphQL file. + + ## 0.11.0 (2023-12-05) - Removed `model_rebuild` calls for generated input, fragment and result models. diff --git a/README.md b/README.md index 57513eb6..d28e66cb 100644 --- a/README.md +++ b/README.md @@ -323,23 +323,45 @@ Example with simple schema and few queries and mutations is available [here](htt ## Generating graphql schema's python representation -Instead of generating client, you can generate file with a copy of GraphQL schema as `GraphQLSchema` declaration. To do this call `ariadne-codegen` with `graphqlschema` argument: +Instead of generating a client, you can generate a file with a copy of a GraphQL schema. To do this call `ariadne-codegen` with `graphqlschema` argument: + ``` ariadne-codegen graphqlschema ``` -`graphqlschema` mode reads configuration from the same place as [`client`](#configuration) but uses only `schema_path`, `remote_schema_url`, `remote_schema_headers`, `remote_schema_verify_ssl` and `plugins` options with addition to some extra options specific to it: +`graphqlschema` mode reads configuration from the same place as [`client`](#configuration) but uses only `schema_path`, `remote_schema_url`, `remote_schema_headers`, `remote_schema_verify_ssl` options to retrieve the schema and `plugins` option to load plugins. + +In addition to the above, `graphqlschema` mode also accepts additional settings specific to it: + + +### `target_file_path` -- `target_file_path` (defaults to `"schema.py"`) - destination path for generated file -- `schema_variable_name` (defaults to `"schema"`) - name for schema variable, must be valid python identifier -- `type_map_variable_name` (defaults to `"type_map"`) - name for type map variable, must be valid python identifier +A string with destination path for generated file. Must be either a Python (`.py`), or GraphQL (`.graphql` or `.gql`) file. -Generated file contains: +Defaults to `schema.py`. + +Generated Python file will contain: - Necessary imports - Type map declaration `{type_map_variable_name}: TypeMap = {...}` - Schema declaration `{schema_variable_name}: GraphQLSchema = GraphQLSchema(...)` +Generated GraphQL file will contain a formatted output of the `print_schema` function from the `graphql-core` package. + + +### `schema_variable_name` + +A string with a name for schema variable, must be valid python identifier. + +Defaults to `"schema"`. Used only if target is a Python file. + + +### `type_map_variable_name` + +A string with a name for type map variable, must be valid python identifier. + +Defaults to `"type_map"`. Used only if target is a Python file. + ## Contributing diff --git a/ariadne_codegen/graphql_schema_generators/schema.py b/ariadne_codegen/graphql_schema_generators/schema.py index a1587b14..9de87871 100644 --- a/ariadne_codegen/graphql_schema_generators/schema.py +++ b/ariadne_codegen/graphql_schema_generators/schema.py @@ -1,7 +1,7 @@ import ast from pathlib import Path -from graphql import GraphQLSchema +from graphql import GraphQLSchema, print_schema from graphql.type.schema import TypeMap from ..codegen import ( @@ -23,7 +23,11 @@ from .utils import get_optional_named_type -def generate_graphql_schema_file( +def generate_graphql_schema_graphql_file(schema: GraphQLSchema, target_file_path: str): + Path(target_file_path).write_text(print_schema(schema), encoding="UTF-8") + + +def generate_graphql_schema_python_file( schema: GraphQLSchema, target_file_path: str, type_map_name: str, diff --git a/ariadne_codegen/main.py b/ariadne_codegen/main.py index 8f7531fa..57eb60c5 100644 --- a/ariadne_codegen/main.py +++ b/ariadne_codegen/main.py @@ -5,7 +5,10 @@ from .client_generators.package import get_package_generator from .config import get_client_settings, get_config_dict, get_graphql_schema_settings -from .graphql_schema_generators.schema import generate_graphql_schema_file +from .graphql_schema_generators.schema import ( + generate_graphql_schema_graphql_file, + generate_graphql_schema_python_file, +) from .plugins.explorer import get_plugins_types from .plugins.manager import PluginManager from .schema import ( @@ -99,9 +102,15 @@ def graphql_schema(config_dict): sys.stdout.write(settings.used_settings_message) - generate_graphql_schema_file( - schema=schema, - target_file_path=settings.target_file_path, - type_map_name=settings.type_map_variable_name, - schema_variable_name=settings.schema_variable_name, - ) + if settings.target_file_format == "py": + generate_graphql_schema_python_file( + schema=schema, + target_file_path=settings.target_file_path, + type_map_name=settings.type_map_variable_name, + schema_variable_name=settings.schema_variable_name, + ) + else: + generate_graphql_schema_graphql_file( + schema=schema, + target_file_path=settings.target_file_path, + ) diff --git a/ariadne_codegen/settings.py b/ariadne_codegen/settings.py index 96c967a0..fbd8f427 100644 --- a/ariadne_codegen/settings.py +++ b/ariadne_codegen/settings.py @@ -195,6 +195,8 @@ class GraphQLSchemaSettings(BaseSettings): def __post_init__(self): super().__post_init__() + + assert_string_is_valid_schema_target_filename(self.target_file_path) assert_string_is_valid_python_identifier(self.schema_variable_name) assert_string_is_valid_python_identifier(self.type_map_variable_name) @@ -206,17 +208,32 @@ def used_settings_message(self): if self.plugins else "No plugin is being used." ) + + 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} + 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. + {plugins_msg} + """ + ) + return dedent( f"""\ Selected strategy: {Strategy.GRAPHQL_SCHEMA} - Using schema from '{self.schema_path or self.remote_schema_url}'. - 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. + Using schema from {self.schema_path or self.remote_schema_url} + Saving graphql schema to: {self.target_file_path} {plugins_msg} """ ) + @property + def target_file_format(self): + return Path(self.target_file_path).suffix[1:].lower() + def assert_path_exists(path: str): if not Path(path).exists(): @@ -233,10 +250,25 @@ def assert_path_is_valid_file(path: str): raise InvalidConfiguration(f"Provided path {path} isn't a file.") +def assert_string_is_valid_schema_target_filename(filename: str): + file_type = Path(filename).suffix + if not file_type: + raise InvalidConfiguration( + f"Provided file name {filename} is missing a file type." + ) + + file_type = file_type[1:].lower() + if file_type not in ("py", "graphql", "gql"): + raise InvalidConfiguration( + f"Provided file name {filename} has an invalid type {file_type}." + " Valid types are py, graphql and gql." + ) + + def assert_string_is_valid_python_identifier(name: str): if not name.isidentifier() and not iskeyword(name): raise InvalidConfiguration( - f"Provided name {name} cannot be used as python indetifier" + f"Provided name {name} cannot be used as python identifier." ) diff --git a/tests/graphql_schema_generators/test_schema.py b/tests/graphql_schema_generators/test_schema.py index 7b7e0980..b7d9bc2c 100644 --- a/tests/graphql_schema_generators/test_schema.py +++ b/tests/graphql_schema_generators/test_schema.py @@ -1,9 +1,10 @@ import ast -from graphql import Undefined, build_schema +from graphql import Undefined, build_schema, print_schema from ariadne_codegen.graphql_schema_generators.schema import ( - generate_graphql_schema_file, + generate_graphql_schema_graphql_file, + generate_graphql_schema_python_file, generate_schema, generate_schema_module, generate_type_map, @@ -28,11 +29,30 @@ """ -def test_generate_graphql_schema_file_creates_file_with_variables(tmp_path): +def test_generate_graphql_schema_graphql_file_creates_file_with_printed_schema( + tmp_path, +): + schema = build_schema(SCHEMA_STR) + file_path = tmp_path / "test_schema.graphql" + + generate_graphql_schema_graphql_file(schema, file_path.as_posix()) + + assert file_path.exists() + assert file_path.is_file() + with file_path.open() as file_: + content = file_.read() + assert content == print_schema(schema) + + +def test_generate_graphql_schema_python_file_creates_py_file_with_variables( + tmp_path, +): schema = build_schema(SCHEMA_STR) file_path = tmp_path / "test_schema.py" - generate_graphql_schema_file(schema, file_path.as_posix(), "type_map", "schema") + generate_graphql_schema_python_file( + schema, file_path.as_posix(), "type_map", "schema" + ) assert file_path.exists() assert file_path.is_file() diff --git a/tests/main/graphql_schemas/example/expected_schema.gql b/tests/main/graphql_schemas/example/expected_schema.gql new file mode 100644 index 00000000..2e19a47b --- /dev/null +++ b/tests/main/graphql_schemas/example/expected_schema.gql @@ -0,0 +1,59 @@ +type Query { + users(country: String): [User!]! +} + +type Mutation { + userCreate(userData: UserCreateInput!): User + userPreferences(data: UserPreferencesInput): Boolean! +} + +input UserCreateInput { + firstName: String + lastName: String + email: String! + favouriteColor: Color + location: LocationInput +} + +input LocationInput { + city: String + country: String +} + +type User { + id: ID! + firstName: String + lastName: String + email: String! + favouriteColor: Color + location: Location +} + +type Location { + city: String + country: String +} + +enum Color { + BLACK + WHITE + RED + GREEN + BLUE + YELLOW +} + +input UserPreferencesInput { + luckyNumber: Int = 7 + favouriteWord: String = "word" + colorOpacity: Float = 1 + excludedTags: [String!] = ["offtop", "tag123"] + notificationsPreferences: NotificationsPreferencesInput! = {receiveMails: true, receivePushNotifications: true, receiveSms: false, title: "Mr"} +} + +input NotificationsPreferencesInput { + receiveMails: Boolean! + receivePushNotifications: Boolean! + receiveSms: Boolean! + title: String! +} \ No newline at end of file diff --git a/tests/main/graphql_schemas/example/expected_schema.graphql b/tests/main/graphql_schemas/example/expected_schema.graphql new file mode 100644 index 00000000..2e19a47b --- /dev/null +++ b/tests/main/graphql_schemas/example/expected_schema.graphql @@ -0,0 +1,59 @@ +type Query { + users(country: String): [User!]! +} + +type Mutation { + userCreate(userData: UserCreateInput!): User + userPreferences(data: UserPreferencesInput): Boolean! +} + +input UserCreateInput { + firstName: String + lastName: String + email: String! + favouriteColor: Color + location: LocationInput +} + +input LocationInput { + city: String + country: String +} + +type User { + id: ID! + firstName: String + lastName: String + email: String! + favouriteColor: Color + location: Location +} + +type Location { + city: String + country: String +} + +enum Color { + BLACK + WHITE + RED + GREEN + BLUE + YELLOW +} + +input UserPreferencesInput { + luckyNumber: Int = 7 + favouriteWord: String = "word" + colorOpacity: Float = 1 + excludedTags: [String!] = ["offtop", "tag123"] + notificationsPreferences: NotificationsPreferencesInput! = {receiveMails: true, receivePushNotifications: true, receiveSms: false, title: "Mr"} +} + +input NotificationsPreferencesInput { + receiveMails: Boolean! + receivePushNotifications: Boolean! + receiveSms: Boolean! + title: String! +} \ No newline at end of file diff --git a/tests/main/graphql_schemas/example/pyproject-schema-gql.toml b/tests/main/graphql_schemas/example/pyproject-schema-gql.toml new file mode 100644 index 00000000..557b014f --- /dev/null +++ b/tests/main/graphql_schemas/example/pyproject-schema-gql.toml @@ -0,0 +1,3 @@ +[tool.ariadne-codegen] +schema_path = "schema.graphql" +target_file_path = "expected_schema.gql" diff --git a/tests/main/graphql_schemas/example/pyproject-schema-graphql.toml b/tests/main/graphql_schemas/example/pyproject-schema-graphql.toml new file mode 100644 index 00000000..c9083813 --- /dev/null +++ b/tests/main/graphql_schemas/example/pyproject-schema-graphql.toml @@ -0,0 +1,3 @@ +[tool.ariadne-codegen] +schema_path = "schema.graphql" +target_file_path = "expected_schema.graphql" diff --git a/tests/main/test_main.py b/tests/main/test_main.py index 569ef416..8dc755ae 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -315,6 +315,22 @@ def test_main_can_read_config_from_provided_file(tmp_path): "schema.py", GRAPHQL_SCHEMAS_PATH / "all_types" / "expected_schema.py", ), + ( + ( + GRAPHQL_SCHEMAS_PATH / "example" / "pyproject-schema-graphql.toml", + (GRAPHQL_SCHEMAS_PATH / "example" / "schema.graphql",), + ), + "expected_schema.graphql", + GRAPHQL_SCHEMAS_PATH / "example" / "expected_schema.graphql", + ), + ( + ( + GRAPHQL_SCHEMAS_PATH / "example" / "pyproject-schema-gql.toml", + (GRAPHQL_SCHEMAS_PATH / "example" / "schema.graphql",), + ), + "expected_schema.gql", + GRAPHQL_SCHEMAS_PATH / "example" / "expected_schema.gql", + ), ], indirect=["project_dir"], ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 3a629287..11d03523 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -226,6 +226,45 @@ def test_graphq_schema_settings_without_schema_path_with_remote_schema_url_is_va assert not settings.schema_path +def test_graphql_schema_settings_with_target_file_path_with_py_extension_is_valid(): + settings = GraphQLSchemaSettings( + remote_schema_url="http://testserver/graphq/", + target_file_path="schema_file.py", + ) + + assert settings.target_file_path == "schema_file.py" + assert settings.target_file_format == "py" + + +def test_graphql_schema_settings_with_target_file_with_graphql_extension_is_valid(): + settings = GraphQLSchemaSettings( + remote_schema_url="http://testserver/graphq/", + target_file_path="schema_file.graphql", + ) + + assert settings.target_file_path == "schema_file.graphql" + assert settings.target_file_format == "graphql" + + +def test_graphql_schema_settings_with_target_file_path_with_gql_extension_is_valid(): + settings = GraphQLSchemaSettings( + remote_schema_url="http://testserver/graphq/", + target_file_path="schema_file.gql", + ) + + assert settings.target_file_path == "schema_file.gql" + assert settings.target_file_format == "gql" + + +def test_graphql_schema_settings_target_file_format_is_lowercased(): + settings = GraphQLSchemaSettings( + remote_schema_url="http://testserver/graphq/", + target_file_path="schema_file.GQL", + ) + + assert settings.target_file_format == "gql" + + def test_graphq_schema_settings_without_schema_path_or_remote_schema_url_is_not_valid(): with pytest.raises(InvalidConfiguration): GraphQLSchemaSettings() @@ -236,6 +275,22 @@ def test_graphql_schema_settings_raises_invalid_configuration_for_invalid_schema GraphQLSchemaSettings(schema_path="not_exisitng.graphql") +def test_graphql_schema_settings_with_target_file_missing_extension_raises_exception(): + with pytest.raises(InvalidConfiguration): + GraphQLSchemaSettings( + remote_schema_url="http://testserver/graphq/", + target_file_path="schema_file", + ) + + +def test_graphql_schema_settings_with_target_file_invalid_extension_raises_exception(): + with pytest.raises(InvalidConfiguration): + GraphQLSchemaSettings( + remote_schema_url="http://testserver/graphq/", + target_file_path="schema_file.invalid", + ) + + def test_graphql_schema_settings_with_invalid_schema_variable_name_raises_exception(): with pytest.raises(InvalidConfiguration): GraphQLSchemaSettings(