Repository: mirumee/ariadne

Issue:
Provide utility for loading types from file/directory
We could provide a utility function that would take a path to the filesystem, and:

- if its a file, read it, validate that its correct file, and return SDL string
- if its a directory, glob `.graphql` files, validate each file, and concat them into single SDL string

That way users could easily use single schema definition for both graphql API and frontend tools.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63a8fbd..66b9bfe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
 - Removed support for Python 3.5 and added support for 3.7.
 - Moved to `GraphQL-core-next` that supports `async` resolvers, query execution and implements more recent version of GraphQL spec. If you are updating existing project, you will need to uninstall `graphql-core` before installing `graphql-core-next`, as both libraries use `graphql` namespace.
 - Added `gql()` utility that provides GraphQL string validation on declaration time, and enables use of [Apollo-GraphQL](https://marketplace.visualstudio.com/items?itemName=apollographql.vscode-apollo) plugin in Python code.
+- Added `load_schema_from_path()` utility function that loads GraphQL types from file or directory containing `.graphql` files, also performing syntax validation.
 - Added `start_simple_server()` shortcut function for a quick dev server creation, abstracting away the `GraphQLMiddleware.make_server()` from first time users.
 - `Boolean` built-in scalar now checks the type of each serialized value. Returning values of type other than `bool`, `int` or `float` from a field resolver will result in a `Boolean cannot represent a non boolean value` error.
 - Redefining type in `type_defs` will now result in `TypeError` being raised. This is breaking change from previous behaviour where old type was simply replaced with new one.
@@ -15,4 +16,4 @@
 - Added `snake_case_fallback_resolvers` that populates schema with default resolvers that map `CamelCase` and `PascalCase` field names from schema to `snake_sase` names in Python.
 - Added `ResolverMap` object that enables assignment of resolver functions to schema types.
 - Added `Scalar` object that enables assignment of `serialize`, `parse_value` and `parse_literal` functions to custom scalars.
-- Both `ResolverMap` and `Scalar` are validating if schema defines specified types and/or fields at moment of creation of executable schema, providing better feedback to developer.
\ No newline at end of file
+- Both `ResolverMap` and `Scalar` are validating if schema defines specified types and/or fields at moment of creation of executable schema, providing better feedback to developer.
diff --git a/ariadne/__init__.py b/ariadne/__init__.py
index 8f68984..8e938fd 100644
--- a/ariadne/__init__.py
+++ b/ariadne/__init__.py
@@ -1,4 +1,5 @@
 from .executable_schema import make_executable_schema
+from .load_schema import load_schema_from_path
 from .resolvers import (
     FallbackResolversSetter,
     ResolverMap,
@@ -13,6 +14,7 @@ from .simple_server import start_simple_server
 from .utils import convert_camel_case_to_snake, gql
 from .wsgi_middleware import GraphQLMiddleware
 
+
 __all__ = [
     "FallbackResolversSetter",
     "GraphQLMiddleware",
@@ -23,6 +25,7 @@ __all__ = [
     "default_resolver",
     "fallback_resolvers",
     "gql",
+    "load_schema_from_path",
     "make_executable_schema",
     "resolve_to",
     "snake_case_fallback_resolvers",
diff --git a/ariadne/exceptions.py b/ariadne/exceptions.py
index 22c4542..6f7d015 100644
--- a/ariadne/exceptions.py
+++ b/ariadne/exceptions.py
@@ -15,3 +15,15 @@ class HttpBadRequestError(HttpError):
 
 class HttpMethodNotAllowedError(HttpError):
     status = HTTP_STATUS_405_METHOD_NOT_ALLOWED
+
+
+class GraphQLFileSyntaxError(Exception):
+    def __init__(self, schema_file, message):
+        super().__init__()
+        self.message = self.format_message(schema_file, message)
+
+    def format_message(self, schema_file, message):
+        return f"Could not load {schema_file}:\n{message}"
+
+    def __str__(self):
+        return self.message
diff --git a/ariadne/load_schema.py b/ariadne/load_schema.py
new file mode 100644
index 0000000..2919a38
--- /dev/null
+++ b/ariadne/load_schema.py
@@ -0,0 +1,31 @@
+import os
+from typing import Generator
+
+from graphql import parse
+from graphql.error import GraphQLSyntaxError
+
+from .exceptions import GraphQLFileSyntaxError
+
+
+def load_schema_from_path(path: str) -> str:
+    if os.path.isdir(path):
+        schema_list = [read_graphql_file(f) for f in walk_graphql_files(path)]
+        return "\n".join(schema_list)
+    return read_graphql_file(os.path.abspath(path))
+
+
+def walk_graphql_files(path: str) -> Generator:
+    def abs_path(f):
+        return os.path.abspath(os.path.join(path, f))
+
+    return (abs_path(f) for f in sorted(os.listdir(path)) if f.endswith(".graphql"))
+
+
+def read_graphql_file(path: str) -> str:
+    with open(path, "r") as graphql_file:
+        schema = graphql_file.read()
+    try:
+        parse(schema)
+    except GraphQLSyntaxError as e:
+        raise GraphQLFileSyntaxError(path, str(e))
+    return schema


Test patch:
diff --git a/tests/test_schema_file_load.py b/tests/test_schema_file_load.py
new file mode 100644
index 0000000..4ff09d1
--- /dev/null
+++ b/tests/test_schema_file_load.py
@@ -0,0 +1,71 @@
+import pytest
+from ariadne import exceptions, load_schema_from_path
+
+FIRST_SCHEMA = """
+    type Query {
+        test: Custom
+    }
+
+    type Custom {
+        node: String
+        default: String
+    }
+"""
+
+
+@pytest.fixture(scope="session")
+def single_file_schema(tmpdir_factory):
+    f = tmpdir_factory.mktemp("schema").join("schema.graphql")
+    f.write(FIRST_SCHEMA)
+    return f
+
+
+def test_load_schema_from_single_file(single_file_schema):
+    assert load_schema_from_path(str(single_file_schema)) == FIRST_SCHEMA
+
+
+INCORRECT_SCHEMA = """
+    type Query {
+        test: Custom
+
+    type Custom {
+        node: String
+        default: String
+    }
+"""
+
+
+@pytest.fixture(scope="session")
+def incorrect_schema_file(tmpdir_factory):
+    f = tmpdir_factory.mktemp("schema").join("schema.graphql")
+    f.write(INCORRECT_SCHEMA)
+    return f
+
+
+def test_loading_schema_fails_on_bad_syntax(incorrect_schema_file):
+    with pytest.raises(exceptions.GraphQLFileSyntaxError) as e:
+        load_schema_from_path(str(incorrect_schema_file))
+    assert str(incorrect_schema_file) in str(e)
+
+
+SECOND_SCHEMA = """
+    type User {
+        name: String
+    }
+"""
+
+
+@pytest.fixture
+def schema_directory(tmpdir_factory):
+    directory = tmpdir_factory.mktemp("schema")
+    first_file = directory.join("base.graphql")
+    first_file.write(FIRST_SCHEMA)
+    second_file = directory.join("user.graphql")
+    second_file.write(SECOND_SCHEMA)
+    return directory
+
+
+def test_loading_schema_from_directory(schema_directory):
+    assert load_schema_from_path(str(schema_directory)) == "\n".join(
+        [FIRST_SCHEMA, SECOND_SCHEMA]
+    )