├── tests ├── __init__.py ├── asgi │ ├── __init__.py │ ├── conftest.py │ └── test_query_execution.py ├── test_connection.py ├── conftest.py └── test_node.py ├── ariadne_relay ├── py.typed ├── utils.py ├── connection │ ├── reference.py │ ├── proxy.py │ ├── __init__.py │ ├── snake_case.py │ └── base.py ├── interfaces.py ├── objects.py ├── __init__.py ├── base.py └── node.py ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── issue_or_bug_report.md └── workflows │ └── tests.yml ├── .flake8 ├── MANIFEST.in ├── .editorconfig ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── .gitignore ├── pyproject.toml ├── CODE_OF_CONDUCT.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ariadne_relay/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/asgi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | application_import_names = ariadne_relay 3 | import_order_style = google 4 | max_line_length = 88 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | graft ariadne_relay 4 | 5 | global-exclude __pycache__ 6 | global-exclude *.py[co] 7 | global-exclude .DS_Store 8 | 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue_or_bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✏ Issue or bug report 3 | about: Use for bug reports or other issues with the codebase. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [*.py] 10 | indent_style = space 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /tests/asgi/conftest.py: -------------------------------------------------------------------------------- 1 | from ariadne.asgi import GraphQL 2 | from graphql import GraphQLSchema 3 | import pytest 4 | from starlette.testclient import TestClient 5 | 6 | 7 | @pytest.fixture 8 | def app(schema: GraphQLSchema) -> GraphQL: 9 | return GraphQL(schema) 10 | 11 | 12 | @pytest.fixture 13 | def client(app: GraphQL) -> TestClient: 14 | return TestClient(app) 15 | -------------------------------------------------------------------------------- /ariadne_relay/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Callable, Optional 3 | 4 | 5 | def is_coroutine_callable(obj: Optional[Callable[..., Any]]) -> bool: 6 | if obj is None: 7 | return False 8 | if asyncio.iscoroutinefunction(obj): 9 | return True 10 | if hasattr(obj, "__call__") and asyncio.iscoroutinefunction( 11 | getattr(obj, "__call__") 12 | ): 13 | return True 14 | return False 15 | -------------------------------------------------------------------------------- /ariadne_relay/connection/reference.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from graphql_relay import ConnectionType, SizedSliceable 4 | 5 | from .base import BaseConnection, ConnectionArguments 6 | 7 | 8 | class ReferenceConnection(BaseConnection[ConnectionType]): 9 | def __call__( 10 | self, 11 | data: SizedSliceable, 12 | connection_args: ConnectionArguments, 13 | *, 14 | data_length: Optional[int] = None, 15 | ) -> ConnectionType: 16 | return self.create_connection(data, connection_args, data_length=data_length) 17 | -------------------------------------------------------------------------------- /ariadne_relay/interfaces.py: -------------------------------------------------------------------------------- 1 | from ariadne import InterfaceType 2 | from graphql import GraphQLObjectType 3 | 4 | from .base import RelayConnectionType 5 | 6 | 7 | class RelayInterfaceType(RelayConnectionType, InterfaceType): 8 | def bind_resolvers_to_graphql_type( 9 | self, graphql_type: GraphQLObjectType, replace_existing: bool = True 10 | ) -> None: 11 | super().bind_resolvers_to_graphql_type( # type: ignore 12 | graphql_type, 13 | replace_existing, 14 | ) 15 | self.bind_connection_resolvers_to_graphql_type(graphql_type, replace_existing) 16 | -------------------------------------------------------------------------------- /ariadne_relay/connection/proxy.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Union 2 | 3 | from graphql_relay import ConnectionType as ReferenceConnectionType 4 | 5 | from .base import BaseConnection, ConnectionArguments 6 | from .snake_case import SnakeCaseConnectionType 7 | 8 | ConnectionType = Union[ReferenceConnectionType, SnakeCaseConnectionType] 9 | ConnectionType_T = TypeVar("ConnectionType_T", bound=ConnectionType) 10 | 11 | 12 | class ConnectionProxy(BaseConnection[ConnectionType]): 13 | def __call__( 14 | self, data: ConnectionType_T, connection_args: ConnectionArguments 15 | ) -> ConnectionType_T: 16 | return data 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.1.0a2 (2021-07-07) 4 | 5 | - Fixed crash when a global_id typename matches a type that is not a NodeType 6 | 7 | 8 | ## 0.1.0a3 (2021-08-06) 9 | 10 | - Implemented `type_resolver` in `NodeObjectType` 11 | - Introduced `NodeInterfaceType` class 12 | 13 | 14 | ## 0.1.0a4 (2022-01-17) 15 | 16 | - Better handling of invalid input in node resolvers 17 | 18 | 19 | ## 0.1.0a5 (2022-01-22) 20 | 21 | - Pin graphql-relay 3.1.3 22 | - Add python 3.10 to CLASSIFIERS in setup.py 23 | 24 | 25 | ## 0.1.0a6 (2022-03-04) 26 | - Remove propagation of instance resolvers via NodeInterfaceType 27 | 28 | 29 | ## 0.1.0a7 (2022-04-15) 30 | - Restrict to ariadne<0.15.0 31 | - Bump graphql-relay to 3.1.5 32 | 33 | 34 | ## 0.1.0a8 (2022-04-20) 35 | - Unblock ariadne and graphql-relay dependencies 36 | - Remove support for python 3.6 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Lint and test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install wheel 28 | pip install -e .[dev,test] 29 | - name: Run unit tests 30 | run: hatch run test 31 | - uses: codecov/codecov-action@v1 32 | - name: Run code quality tests 33 | run: hatch run lint 34 | -------------------------------------------------------------------------------- /ariadne_relay/connection/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( 2 | BaseConnection, 3 | ConnectionArguments, 4 | ConnectionAwaitable, 5 | ConnectionCallable, 6 | ConnectionFactory, 7 | ConnectionFactoryConstructor, 8 | ConnectionFactoryOrConstructor, 9 | ) 10 | from .proxy import ConnectionProxy 11 | from .reference import ReferenceConnection 12 | from .snake_case import ( 13 | SnakeCaseBaseConnection, 14 | SnakeCaseConnection, 15 | SnakeCaseConnectionType, 16 | SnakeCasePageInfoType, 17 | ) 18 | 19 | __all__ = [ 20 | "BaseConnection", 21 | "ConnectionArguments", 22 | "ConnectionAwaitable", 23 | "ConnectionCallable", 24 | "ConnectionFactory", 25 | "ConnectionFactoryConstructor", 26 | "ConnectionFactoryOrConstructor", 27 | "ConnectionProxy", 28 | "ReferenceConnection", 29 | "SnakeCaseBaseConnection", 30 | "SnakeCaseConnection", 31 | "SnakeCaseConnectionType", 32 | "SnakeCasePageInfoType", 33 | ] 34 | -------------------------------------------------------------------------------- /ariadne_relay/objects.py: -------------------------------------------------------------------------------- 1 | from ariadne import ObjectType 2 | from graphql.type import GraphQLObjectType 3 | 4 | from .base import RelayConnectionType 5 | 6 | 7 | class RelayObjectType(RelayConnectionType, ObjectType): 8 | def bind_resolvers_to_graphql_type( 9 | self, graphql_type: GraphQLObjectType, replace_existing: bool = True 10 | ) -> None: 11 | super().bind_resolvers_to_graphql_type( # type: ignore 12 | graphql_type, 13 | replace_existing, 14 | ) 15 | self.bind_connection_resolvers_to_graphql_type(graphql_type, replace_existing) 16 | 17 | 18 | class RelayQueryType(RelayObjectType): 19 | """Convenience class for defining Query type""" 20 | 21 | def __init__(self) -> None: 22 | super().__init__("Query") 23 | 24 | 25 | class RelayMutationType(RelayObjectType): 26 | """Convenience class for defining Mutation type""" 27 | 28 | def __init__(self) -> None: 29 | super().__init__("Mutation") 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Before contributing to Ariadne-Relay, please familiarize yourself with the project's code of conduct, available in the [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) file. 4 | 5 | 6 | ## Reporting bugs, asking for help, offering feedback and ideas 7 | 8 | Please use [GitHub issues](https://github.com/g18e/ariadne-relay/issues) and [GitHub discussions](https://github.com/g18e/ariadne-relay/discussions). 9 | 10 | 11 | ## Development setup 12 | 13 | Ariadne-Relay is written to support Python 3.7, 3.8, 3.9, 3.10, and 3.11. 14 | 15 | The codebase is formatted with [Black](https://black.readthedocs.io/). It is also linted with [flake8] and strictly type-checked with [mypy](http://mypy-lang.org/index.html). 16 | 17 | Tests use [pytest](https://pytest.org/) with [Codecov](https://codecov.io/gh/g18e/ariadne-relay) for monitoring coverage. 18 | 19 | Dev requirements can be installed using Pip extras. For example, 20 | to install all dependencies for doing local development and 21 | running the tests, run `pip install -e .[dev,test]`. 22 | 23 | 24 | ## Goals 25 | 26 | ### Tests 27 | 28 | Before any further feature work, test coverage needs to be at or near 100%. 29 | 30 | 31 | ### 0.1.0 Release 32 | 33 | Beyond tests, the immediate development priority is stabilizing the API and shipping the first release. 34 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | from ariadne import make_executable_schema 2 | from graphql import graphql_sync 3 | from graphql_relay import offset_to_cursor, to_global_id 4 | 5 | from ariadne_relay import NodeObjectType, RelayQueryType 6 | from .conftest import Foo 7 | 8 | 9 | def test_default_connection_factory(type_defs: str, foo_connection_query: str) -> None: 10 | test_nodes = [Foo(id=i) for i in range(10)] 11 | 12 | query_type = RelayQueryType() 13 | query_type.set_connection("foos", lambda *_: test_nodes) 14 | 15 | test_type = NodeObjectType("Foo") 16 | 17 | schema = make_executable_schema(type_defs, query_type, test_type) 18 | 19 | result = graphql_sync(schema, foo_connection_query) 20 | assert result.errors is None 21 | assert result.data == { 22 | "foos": { 23 | "edges": [ 24 | { 25 | "cursor": offset_to_cursor(i), 26 | "node": { 27 | "__typename": obj.__class__.__name__, 28 | "id": to_global_id(obj.__class__.__name__, str(obj.id)), 29 | }, 30 | } 31 | for i, obj in enumerate(test_nodes) 32 | ], 33 | "pageInfo": { 34 | "hasNextPage": False, 35 | "hasPreviousPage": False, 36 | "startCursor": offset_to_cursor(0), 37 | "endCursor": offset_to_cursor(len(test_nodes) - 1), 38 | }, 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, General Intelligence Inc. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /ariadne_relay/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0a8" 2 | 3 | from graphql_relay import ( 4 | ConnectionCursor, 5 | ConnectionType, 6 | Edge, 7 | EdgeConstructor, 8 | EdgeType, 9 | from_global_id, 10 | PageInfo, 11 | PageInfoConstructor, 12 | PageInfoType, 13 | ResolvedGlobalId, 14 | SizedSliceable, 15 | to_global_id, 16 | ) 17 | 18 | from .base import set_default_connection_factory 19 | from .connection import ( 20 | BaseConnection, 21 | ConnectionArguments, 22 | ConnectionProxy, 23 | ReferenceConnection, 24 | SnakeCaseBaseConnection, 25 | SnakeCaseConnection, 26 | SnakeCaseConnectionType, 27 | SnakeCasePageInfoType, 28 | ) 29 | from .interfaces import RelayInterfaceType 30 | from .node import ( 31 | NodeInterfaceType, 32 | NodeObjectType, 33 | resolve_node_query, 34 | resolve_node_query_sync, 35 | ) 36 | from .objects import RelayMutationType, RelayObjectType, RelayQueryType 37 | 38 | __all__ = [ 39 | "BaseConnection", 40 | "ConnectionArguments", 41 | "ConnectionCursor", 42 | "ConnectionProxy", 43 | "ConnectionType", 44 | "Edge", 45 | "EdgeConstructor", 46 | "EdgeType", 47 | "from_global_id", 48 | "NodeInterfaceType", 49 | "NodeObjectType", 50 | "PageInfo", 51 | "PageInfoConstructor", 52 | "PageInfoType", 53 | "ReferenceConnection", 54 | "RelayInterfaceType", 55 | "RelayMutationType", 56 | "RelayObjectType", 57 | "RelayQueryType", 58 | "ResolvedGlobalId", 59 | "resolve_node_query", 60 | "resolve_node_query_sync", 61 | "set_default_connection_factory", 62 | "SizedSliceable", 63 | "SnakeCaseBaseConnection", 64 | "SnakeCaseConnection", 65 | "SnakeCaseConnectionType", 66 | "SnakeCasePageInfoType", 67 | "to_global_id", 68 | ] 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | pip-wheel-metadata/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # OS files 40 | .DS_Store 41 | Thumbs.db 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # Vscode 81 | .vscode 82 | .devcontainer 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # celery beat schedule file 88 | celerybeat-schedule 89 | 90 | # SageMath parsed files 91 | *.sage.py 92 | 93 | # Environments 94 | .env 95 | .venv 96 | env/ 97 | venv/ 98 | ENV/ 99 | env.bak/ 100 | venv.bak/ 101 | 102 | # Spyder project settings 103 | .spyderproject 104 | .spyproject 105 | 106 | # Rope project settings 107 | .ropeproject 108 | 109 | # mkdocs documentation 110 | /site 111 | 112 | # mypy 113 | .mypy_cache/ 114 | 115 | # PyCharm 116 | .idea 117 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "ariadne-relay" 7 | description = """\ 8 | Ariadne-Relay provides a toolset for implementing GraphQL servers \ 9 | in Python that conform to the Relay specification, using the \ 10 | Ariadne library.\ 11 | """ 12 | authors = [{ name = "General Intelligence Inc.", email = "info@g18e.com" }] 13 | dynamic = ["version"] 14 | readme = "README.md" 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: BSD License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python", 21 | "Programming Language :: Python :: 3.7", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | ] 28 | dependencies = ["ariadne>=0.15.0", "graphql-relay>=3.2.0"] 29 | 30 | [project.optional-dependencies] 31 | dev = ["black", "hatch", "mypy<1.0", "flake8", "flake8-builtins", "flake8-import-order"] 32 | test = ["ariadne[dev,test]", "hatch", "pytest", "pytest-asyncio", "pytest-cov"] 33 | 34 | [project.urls] 35 | Source = "https://github.com/g18e/ariadne-relay" 36 | 37 | [tool.black] 38 | line-length = 88 39 | target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] 40 | include = '\.pyi?$' 41 | exclude = ''' 42 | /( 43 | \.eggs 44 | | \.git 45 | | \.hg 46 | | \.mypy_cache 47 | | \.tox 48 | | \.venv 49 | | _build 50 | | buck-out 51 | | build 52 | | dist 53 | | snapshots 54 | )/ 55 | ''' 56 | 57 | [tool.coverage.run] 58 | source = ["ariadne_relay", "tests"] 59 | 60 | [tool.hatch.envs.default] 61 | features = ["dev", "test"] 62 | 63 | [tool.hatch.envs.default.scripts] 64 | lint = "flake8 && black --check . && mypy" 65 | test = "coverage run -m pytest" 66 | 67 | [tool.hatch.version] 68 | path = "ariadne_relay/__init__.py" 69 | 70 | [tool.isort] 71 | force_single_line = false 72 | include_trailing_comma = true 73 | known_first_party = "ariadne_relay" 74 | line_length = 88 75 | multi_line_output = 3 76 | no_lines_before = "LOCALFOLDER" 77 | profile = "google" 78 | 79 | [tool.mypy] 80 | files = ["ariadne_relay", "tests"] 81 | follow_imports = "silent" 82 | ignore_missing_imports = true 83 | strict = true 84 | 85 | [tool.pytest.ini_options] 86 | asyncio_mode = "strict" 87 | -------------------------------------------------------------------------------- /ariadne_relay/connection/snake_case.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Optional 3 | 4 | try: 5 | from typing import Protocol 6 | except ImportError: # Python < 3.8 7 | from typing_extensions import Protocol # type: ignore 8 | 9 | from graphql_relay import ( 10 | ConnectionCursor, 11 | EdgeConstructor, 12 | EdgeType, 13 | PageInfoConstructor, 14 | PageInfoType, 15 | SizedSliceable, 16 | ) 17 | 18 | from .base import ( 19 | BaseConnection, 20 | ConnectionArguments, 21 | ConnectionConstructor, 22 | ConnectionType_T, 23 | ) 24 | 25 | 26 | class SnakeCasePageInfoType(Protocol): 27 | @property 28 | def start_cursor(self) -> Optional[ConnectionCursor]: 29 | ... 30 | 31 | @property 32 | def end_cursor(self) -> Optional[ConnectionCursor]: 33 | ... 34 | 35 | @property 36 | def has_previous_page(self) -> Optional[bool]: 37 | ... 38 | 39 | @property 40 | def has_next_page(self) -> Optional[bool]: 41 | ... 42 | 43 | 44 | class SnakeCaseConnectionType(Protocol): 45 | @property 46 | def edges(self) -> List[EdgeType]: 47 | ... 48 | 49 | @property 50 | def page_info(self) -> SnakeCasePageInfoType: 51 | ... 52 | 53 | 54 | @dataclass(frozen=True, init=False) 55 | class PageInfo: 56 | start_cursor: Optional[ConnectionCursor] 57 | end_cursor: Optional[ConnectionCursor] 58 | has_previous_page: Optional[bool] 59 | has_next_page: Optional[bool] 60 | 61 | def __init__( 62 | self, 63 | *, 64 | startCursor: Optional[ConnectionCursor] = None, 65 | endCursor: Optional[ConnectionCursor] = None, 66 | hasPreviousPage: Optional[bool] = None, 67 | hasNextPage: Optional[bool] = None, 68 | ) -> None: 69 | object.__setattr__(self, "start_cursor", startCursor) 70 | object.__setattr__(self, "end_cursor", endCursor) 71 | object.__setattr__(self, "has_previous_page", hasPreviousPage) 72 | object.__setattr__(self, "has_next_page", hasNextPage) 73 | 74 | 75 | @dataclass(frozen=True, init=False) 76 | class Connection: 77 | edges: List[EdgeType] 78 | page_info: SnakeCasePageInfoType 79 | 80 | def __init__( 81 | self, 82 | *, 83 | edges: List[EdgeType], 84 | pageInfo: PageInfoType, 85 | ) -> None: 86 | object.__setattr__(self, "edges", edges) 87 | object.__setattr__(self, "page_info", pageInfo) 88 | 89 | 90 | class SnakeCaseBaseConnection(BaseConnection[ConnectionType_T]): 91 | def __init__( 92 | self, 93 | *, 94 | connection_type: Optional[ConnectionConstructor[ConnectionType_T]] = Connection, 95 | edge_type: Optional[EdgeConstructor] = None, 96 | page_info_type: Optional[PageInfoConstructor] = None, 97 | ) -> None: 98 | self._connection_type = connection_type 99 | self._edge_type = edge_type 100 | self._page_info_type = page_info_type or PageInfo 101 | 102 | 103 | class SnakeCaseConnection(SnakeCaseBaseConnection[Connection]): 104 | def __call__( 105 | self, 106 | data: SizedSliceable, 107 | connection_args: ConnectionArguments, 108 | *, 109 | data_length: Optional[int] = None, 110 | ) -> SnakeCaseConnectionType: 111 | return self.create_connection(data, connection_args, data_length=data_length) 112 | -------------------------------------------------------------------------------- /ariadne_relay/connection/base.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Any, 3 | Awaitable, 4 | Callable, 5 | cast, 6 | Dict, 7 | Generic, 8 | List, 9 | NamedTuple, 10 | Optional, 11 | TypeVar, 12 | Union, 13 | ) 14 | 15 | try: 16 | from typing import Protocol 17 | except ImportError: # Python < 3.8 18 | from typing_extensions import Protocol # type: ignore 19 | 20 | from graphql_relay import ( 21 | connection_from_array_slice, 22 | EdgeConstructor, 23 | EdgeType, 24 | PageInfoConstructor, 25 | PageInfoType, 26 | SizedSliceable, 27 | ) 28 | 29 | ConnectionType_T = TypeVar("ConnectionType_T", covariant=True) 30 | 31 | 32 | class ConnectionConstructor(Protocol[ConnectionType_T]): 33 | def __call__( 34 | self, 35 | *, 36 | edges: List[EdgeType], 37 | pageInfo: PageInfoType, 38 | ) -> ConnectionType_T: 39 | ... 40 | 41 | 42 | class ConnectionArguments(NamedTuple): 43 | after: Optional[str] = None 44 | before: Optional[str] = None 45 | first: Optional[int] = None 46 | last: Optional[int] = None 47 | 48 | 49 | ConnectionAwaitable = Callable[..., Awaitable[Any]] 50 | ConnectionCallable = Callable[..., Any] 51 | ConnectionFactory = Union[ConnectionAwaitable, ConnectionCallable] 52 | 53 | 54 | class AsyncConnectionFactoryConstructor(Protocol): 55 | def __init__(self, *args: Any, **kwargs: Any) -> None: 56 | ... 57 | 58 | async def __call__( 59 | self, 60 | data: SizedSliceable, 61 | connection_args: ConnectionArguments, 62 | *args: Any, 63 | **kwargs: Any, 64 | ) -> Any: 65 | ... 66 | 67 | 68 | class SyncConnectionFactoryConstructor(Protocol): 69 | def __init__(self, *args: Any, **kwargs: Any) -> None: 70 | ... 71 | 72 | def __call__( 73 | self, 74 | data: SizedSliceable, 75 | connection_args: ConnectionArguments, 76 | *args: Any, 77 | **kwargs: Any, 78 | ) -> Any: 79 | ... 80 | 81 | 82 | ConnectionFactoryConstructor = Union[ 83 | AsyncConnectionFactoryConstructor, SyncConnectionFactoryConstructor 84 | ] 85 | ConnectionFactoryOrConstructor = Union[ConnectionFactory, ConnectionFactoryConstructor] 86 | 87 | 88 | class BaseConnection(Generic[ConnectionType_T]): 89 | def __init__( 90 | self, 91 | *, 92 | connection_type: Optional[ConnectionConstructor[ConnectionType_T]] = None, 93 | edge_type: Optional[EdgeConstructor] = None, 94 | page_info_type: Optional[PageInfoConstructor] = None, 95 | ) -> None: 96 | self._connection_type = connection_type 97 | self._edge_type = edge_type 98 | self._page_info_type = page_info_type 99 | 100 | def create_connection( 101 | self, 102 | data: SizedSliceable, 103 | connection_args: ConnectionArguments, 104 | *, 105 | data_length: Optional[int] = None, 106 | ) -> ConnectionType_T: 107 | data_length = len(data) if data_length is None else data_length 108 | kwargs: Dict[str, Any] = dict(array_slice_length=data_length) 109 | if self._connection_type is not None: 110 | kwargs["connection_type"] = self._connection_type 111 | if self._edge_type is not None: 112 | kwargs["edge_type"] = self._edge_type 113 | if self._page_info_type is not None: 114 | kwargs["page_info_type"] = self._page_info_type 115 | return cast( 116 | ConnectionType_T, 117 | connection_from_array_slice( 118 | data, 119 | connection_args._asdict(), 120 | **kwargs, 121 | ), 122 | ) 123 | -------------------------------------------------------------------------------- /tests/asgi/test_query_execution.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from graphql_relay import offset_to_cursor, to_global_id 4 | from starlette.testclient import TestClient 5 | 6 | from ..conftest import Foo, Qux 7 | 8 | 9 | def test_query_node_instance_resolver( 10 | client: TestClient, 11 | node_query: str, 12 | foo_nodes: Dict[str, Foo], 13 | ) -> None: 14 | test_node = next(iter(foo_nodes.values())) 15 | type_name = test_node.__class__.__name__ 16 | global_id = to_global_id(type_name, str(test_node.id)) 17 | response = client.post( 18 | "/", 19 | json={ 20 | "query": node_query, 21 | "variables": { 22 | "id": global_id, 23 | }, 24 | }, 25 | ) 26 | assert response.status_code == 200 27 | assert response.json() == { 28 | "data": { 29 | "node": { 30 | "__typename": type_name, 31 | "id": global_id, 32 | } 33 | } 34 | } 35 | 36 | 37 | def test_query_node_interface_instance_resolver( 38 | client: TestClient, 39 | node_query: str, 40 | qux_nodes: Dict[str, Qux], 41 | ) -> None: 42 | test_node = next(iter(qux_nodes.values())) 43 | type_name = test_node.__class__.__name__ 44 | global_id = to_global_id("Baz", str(test_node.id)) 45 | response = client.post( 46 | "/", 47 | json={ 48 | "query": node_query, 49 | "variables": { 50 | "id": global_id, 51 | }, 52 | }, 53 | ) 54 | assert response.status_code == 200 55 | assert response.json() == { 56 | "data": { 57 | "node": { 58 | "__typename": type_name, 59 | "id": global_id, 60 | } 61 | } 62 | } 63 | 64 | 65 | def test_query_non_node_typename( 66 | client: TestClient, 67 | node_query: str, 68 | ) -> None: 69 | global_id = to_global_id("Bar", "bar") 70 | response = client.post( 71 | "/", 72 | json={ 73 | "query": node_query, 74 | "variables": { 75 | "id": global_id, 76 | }, 77 | }, 78 | ) 79 | assert response.status_code == 200 80 | assert response.json() == { 81 | "data": { 82 | "node": None, 83 | } 84 | } 85 | 86 | 87 | def test_node_typename_resolver( 88 | client: TestClient, 89 | qux_connection_query: str, 90 | qux_nodes: Dict[str, Foo], 91 | ) -> None: 92 | response = client.post("/", json={"query": qux_connection_query}) 93 | assert response.status_code == 200 94 | assert response.json() == { 95 | "data": { 96 | "quxes": { 97 | "edges": [ 98 | { 99 | "node": { 100 | "__typename": obj.__class__.__name__, 101 | "id": to_global_id("Baz", str(obj.id)), 102 | }, 103 | } 104 | for obj in qux_nodes.values() 105 | ], 106 | }, 107 | } 108 | } 109 | 110 | 111 | def test_connection_query( 112 | client: TestClient, 113 | foo_connection_query: str, 114 | foo_nodes: Dict[str, Foo], 115 | ) -> None: 116 | response = client.post("/", json={"query": foo_connection_query}) 117 | assert response.status_code == 200 118 | assert response.json() == { 119 | "data": { 120 | "foos": { 121 | "edges": [ 122 | { 123 | "cursor": offset_to_cursor(i), 124 | "node": { 125 | "__typename": obj.__class__.__name__, 126 | "id": to_global_id(obj.__class__.__name__, str(obj.id)), 127 | }, 128 | } 129 | for i, obj in enumerate(foo_nodes.values()) 130 | ], 131 | "pageInfo": { 132 | "hasNextPage": False, 133 | "hasPreviousPage": False, 134 | "startCursor": offset_to_cursor(0), 135 | "endCursor": offset_to_cursor(len(foo_nodes) - 1), 136 | }, 137 | }, 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Dict 3 | 4 | from ariadne import InterfaceType, make_executable_schema, ObjectType 5 | from graphql import GraphQLSchema 6 | import pytest 7 | 8 | from ariadne_relay import RelayQueryType, resolve_node_query_sync 9 | from ariadne_relay.node import NodeInterfaceType, NodeObjectType 10 | 11 | 12 | @dataclass(frozen=True) 13 | class Foo: 14 | id: int # noqa: A003 15 | 16 | 17 | @dataclass(frozen=True) 18 | class Qux: 19 | id: int # noqa: A003 20 | 21 | 22 | @pytest.fixture 23 | def type_defs() -> str: 24 | return """ 25 | type Query { 26 | node(id: ID!): Node 27 | foos( 28 | after: String 29 | before: String 30 | first: Int 31 | last: Int 32 | ): FoosConnection! 33 | quxes( 34 | after: String 35 | before: String 36 | first: Int 37 | last: Int 38 | ): QuxesConnection! 39 | } 40 | 41 | interface Node { 42 | id: ID! 43 | } 44 | 45 | type PageInfo { 46 | hasNextPage: Boolean! 47 | hasPreviousPage: Boolean! 48 | startCursor: String 49 | endCursor: String 50 | } 51 | 52 | type Foo implements Node { 53 | id: ID! 54 | } 55 | 56 | type FooEdge { 57 | cursor: String! 58 | node: Foo 59 | } 60 | 61 | type FoosConnection { 62 | pageInfo: PageInfo! 63 | edges: [FooEdge]! 64 | } 65 | 66 | type Bar { 67 | id: String! 68 | } 69 | 70 | interface Baz { 71 | id: ID! 72 | } 73 | 74 | type Qux implements Node & Baz { 75 | id: ID! 76 | } 77 | 78 | type QuxEdge { 79 | cursor: String! 80 | node: Qux 81 | } 82 | 83 | type QuxesConnection { 84 | pageInfo: PageInfo! 85 | edges: [QuxEdge]! 86 | } 87 | """ 88 | 89 | 90 | @pytest.fixture 91 | def foo_nodes() -> Dict[str, Foo]: 92 | return {str(i): Foo(id=i) for i in range(10)} 93 | 94 | 95 | @pytest.fixture 96 | def qux_nodes() -> Dict[str, Qux]: 97 | return {str(i): Qux(id=i) for i in range(10)} 98 | 99 | 100 | @pytest.fixture 101 | def query_type(foo_nodes: Dict[str, Foo], qux_nodes: Dict[str, Qux]) -> RelayQueryType: 102 | query_type = RelayQueryType() 103 | query_type.set_field("node", resolve_node_query_sync) 104 | query_type.set_connection("foos", lambda *_: list(foo_nodes.values())) 105 | query_type.set_connection("quxes", lambda *_: list(qux_nodes.values())) 106 | return query_type 107 | 108 | 109 | @pytest.fixture 110 | def node_interface_type() -> InterfaceType: 111 | node_interface_type = InterfaceType("Node") 112 | node_interface_type.set_type_resolver(lambda obj, *_: obj.__class__.__name__) 113 | return node_interface_type 114 | 115 | 116 | @pytest.fixture 117 | def baz_interface_type(qux_nodes: Dict[str, Qux]) -> NodeInterfaceType: 118 | baz_interface_type = NodeInterfaceType("Baz") 119 | baz_interface_type.set_typename_resolver(lambda obj, *_: "Baz") 120 | baz_interface_type.set_type_resolver(lambda obj, *_: obj.__class__.__name__) 121 | baz_interface_type.set_instance_resolver(lambda id, *_: qux_nodes[id]) 122 | return baz_interface_type 123 | 124 | 125 | @pytest.fixture 126 | def foo_type(foo_nodes: Dict[str, Foo]) -> NodeObjectType: 127 | foo_type = NodeObjectType("Foo") 128 | foo_type.set_instance_resolver(lambda id, *_: foo_nodes[id]) 129 | return foo_type 130 | 131 | 132 | @pytest.fixture 133 | def bar_type() -> ObjectType: 134 | return ObjectType("Bar") 135 | 136 | 137 | @pytest.fixture 138 | def qux_type() -> NodeObjectType: 139 | return NodeObjectType("Qux") 140 | 141 | 142 | @pytest.fixture 143 | def schema( 144 | type_defs: str, 145 | query_type: RelayQueryType, 146 | node_interface_type: InterfaceType, 147 | baz_interface_type: NodeInterfaceType, 148 | foo_type: NodeObjectType, 149 | bar_type: ObjectType, 150 | qux_type: NodeObjectType, 151 | ) -> GraphQLSchema: 152 | return make_executable_schema( 153 | type_defs, 154 | [ 155 | query_type, 156 | node_interface_type, 157 | baz_interface_type, 158 | foo_type, 159 | bar_type, 160 | qux_type, 161 | ], 162 | ) 163 | 164 | 165 | @pytest.fixture 166 | def node_query() -> str: 167 | return "query($id: ID!) { node(id: $id) { __typename, id } }" 168 | 169 | 170 | @pytest.fixture 171 | def foo_connection_query() -> str: 172 | return """ 173 | { 174 | foos { 175 | edges { 176 | cursor 177 | node { 178 | __typename 179 | id 180 | } 181 | } 182 | pageInfo { 183 | hasNextPage 184 | hasPreviousPage 185 | startCursor 186 | endCursor 187 | } 188 | } 189 | } 190 | """ 191 | 192 | 193 | @pytest.fixture 194 | def qux_connection_query() -> str: 195 | return """ 196 | { 197 | quxes { 198 | edges { 199 | node { 200 | __typename 201 | id 202 | } 203 | } 204 | } 205 | } 206 | """ 207 | -------------------------------------------------------------------------------- /ariadne_relay/base.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from inspect import isawaitable 3 | from typing import Any, Callable, cast, Dict, Optional 4 | 5 | from ariadne.types import Resolver 6 | from graphql import GraphQLObjectType, GraphQLResolveInfo 7 | 8 | from .connection import ( 9 | ConnectionArguments, 10 | ConnectionAwaitable, 11 | ConnectionCallable, 12 | ConnectionFactory, 13 | ConnectionFactoryOrConstructor, 14 | ReferenceConnection, 15 | ) 16 | from .utils import is_coroutine_callable 17 | 18 | 19 | @dataclass 20 | class RelayConnectionConfig: 21 | factory: ConnectionFactory 22 | resolver: Resolver 23 | 24 | 25 | DefaultConnectionFactory: ConnectionFactoryOrConstructor = ReferenceConnection 26 | 27 | 28 | def set_default_connection_factory(factory: ConnectionFactoryOrConstructor) -> None: 29 | global DefaultConnectionFactory 30 | DefaultConnectionFactory = factory 31 | 32 | 33 | class RelayConnectionType: 34 | __connection_config: Optional[Dict[str, RelayConnectionConfig]] = None 35 | 36 | @property 37 | def _connection_configs(self) -> Dict[str, RelayConnectionConfig]: 38 | if self.__connection_config is None: 39 | self.__connection_config = {} 40 | return self.__connection_config 41 | 42 | def connection( 43 | self, 44 | name: str, 45 | *, 46 | factory: Optional[ConnectionFactoryOrConstructor] = None, 47 | ) -> Callable[[Resolver], Resolver]: 48 | if not isinstance(name, str): 49 | raise ValueError( 50 | "connection decorator should be passed " 51 | 'a field name: @foo.connection("name")' 52 | ) 53 | return self.create_register_connection_resolver(name, factory=factory) 54 | 55 | def create_register_connection_resolver( 56 | self, 57 | name: str, 58 | *, 59 | factory: Optional[ConnectionFactoryOrConstructor] = None, 60 | ) -> Callable[[Resolver], Resolver]: 61 | def register_connection_resolver(f: Resolver) -> Resolver: 62 | self.set_connection(name, f, factory=factory) 63 | return f 64 | 65 | return register_connection_resolver 66 | 67 | def set_connection( 68 | self, 69 | name: str, 70 | resolver: Resolver, 71 | *, 72 | factory: Optional[ConnectionFactoryOrConstructor] = None, 73 | ) -> Resolver: 74 | factory = factory or DefaultConnectionFactory 75 | factory_instance: ConnectionFactory = ( 76 | factory() if isinstance(factory, type) else factory 77 | ) 78 | self._connection_configs[name] = RelayConnectionConfig( 79 | factory=factory_instance, 80 | resolver=resolver, 81 | ) 82 | return resolver 83 | 84 | def bind_connection_resolvers_to_graphql_type( 85 | self, 86 | graphql_type: GraphQLObjectType, 87 | replace_existing: bool = True, 88 | ) -> None: 89 | for field_name, config in self._connection_configs.items(): 90 | if field_name not in graphql_type.fields: 91 | raise ValueError( 92 | "Field %s is not defined on type %s" 93 | % (field_name, getattr(self, "name")) 94 | ) 95 | if graphql_type.fields[field_name].resolve is None or replace_existing: 96 | connection_field = graphql_type.fields[field_name] 97 | if is_coroutine_callable(config.resolver) or is_coroutine_callable( 98 | config.factory 99 | ): 100 | connection_field.resolve = create_connection_resolver( 101 | config.resolver, config.factory 102 | ) 103 | else: 104 | connection_field.resolve = create_connection_resolver_sync( 105 | config.resolver, 106 | cast(ConnectionCallable, config.factory), 107 | ) 108 | 109 | 110 | def create_connection_resolver( 111 | resolver: Resolver, factory: ConnectionFactory 112 | ) -> ConnectionAwaitable: 113 | async def resolve_connection( 114 | obj: Any, 115 | info: GraphQLResolveInfo, 116 | *, 117 | after: Optional[str] = None, 118 | before: Optional[str] = None, 119 | first: Optional[int] = None, 120 | last: Optional[int] = None, 121 | **kwargs: Any, 122 | ) -> Any: 123 | connection_args = ConnectionArguments( 124 | after=after, before=before, first=first, last=last 125 | ) 126 | data = resolver(obj, info, connection_args, **kwargs) 127 | if isawaitable(data): 128 | data = await data 129 | connection = factory(data, connection_args) 130 | if isawaitable(connection): 131 | connection = await connection 132 | return connection 133 | 134 | return resolve_connection 135 | 136 | 137 | def create_connection_resolver_sync( 138 | resolver: Resolver, factory: ConnectionCallable 139 | ) -> ConnectionCallable: 140 | def resolve_connection( 141 | obj: Any, 142 | info: GraphQLResolveInfo, 143 | *, 144 | after: Optional[str] = None, 145 | before: Optional[str] = None, 146 | first: Optional[int] = None, 147 | last: Optional[int] = None, 148 | **kwargs: Any, 149 | ) -> Any: 150 | connection_args = ConnectionArguments( 151 | after=after, before=before, first=first, last=last 152 | ) 153 | data = resolver(obj, info, connection_args, **kwargs) 154 | return factory(data, connection_args) 155 | 156 | return resolve_connection 157 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | . 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/g18e/ariadne-relay/actions/workflows/tests.yml/badge.svg?branch=main) 2 | [![Codecov](https://codecov.io/gh/g18e/ariadne-relay/branch/main/graph/badge.svg)](https://codecov.io/gh/g18e/ariadne-relay) 3 | 4 | - - - - - 5 | 6 | # Ariadne-Relay 7 | 8 | Ariadne-Relay provides a toolset for implementing [GraphQL](http://graphql.github.io/) servers 9 | in Python that conform to the [Relay specification](https://relay.dev/docs/guides/graphql-server-specification/), 10 | using the [Ariadne](https://ariadnegraphql.org) library. 11 | 12 | The goals of Ariadne-Relay are to: 13 | 14 | - Make building Relay features feel as close as possible to core Ariadne 15 | - Minimize boilerplate for common cases 16 | - Make it as easy as possible to fully customize and optimize a Relay deployment 17 | 18 | 19 | ## Installation 20 | 21 | Ariadne-Relay can be installed with pip: 22 | 23 | ```console 24 | pip install ariadne-relay 25 | ``` 26 | 27 | 28 | ## Quickstart 29 | 30 | If you are not familiar with Ariadne usage in general, the [Araidne docs](https://ariadnegraphql.org/docs/intro) are the best place to start. 31 | 32 | Here's a variation of the Ariadne quickstart as a Relay implementation: 33 | 34 | ```python 35 | from dataclasses import dataclass 36 | 37 | from ariadne import gql, InterfaceType, make_executable_schema 38 | from ariadne.asgi import GraphQL 39 | 40 | from ariadne_relay import NodeObjectType, RelayQueryType, resolve_node_query 41 | 42 | 43 | # Using a dataclass for Person rather than a dict, 44 | # since it works better with a Node implementation 45 | @dataclass 46 | class Person: 47 | id: int 48 | firstName: str 49 | lastName: str 50 | age: int 51 | 52 | 53 | type_defs = gql( 54 | """ 55 | type Query { 56 | node(id: ID!): Node 57 | people( 58 | after: String 59 | before: String 60 | first: Int 61 | last: Int 62 | ): PeopleConnection! 63 | } 64 | 65 | interface Node { 66 | id: ID! 67 | } 68 | 69 | type PageInfo { 70 | hasNextPage: Boolean! 71 | hasPreviousPage: Boolean! 72 | startCursor: String 73 | endCursor: String 74 | } 75 | 76 | type Person implements Node { 77 | id: ID! 78 | firstName: String 79 | lastName: String 80 | age: Int 81 | fullName: String 82 | } 83 | 84 | type PersonEdge { 85 | cursor: String! 86 | node: Person 87 | } 88 | 89 | type PeopleConnection { 90 | pageInfo: PageInfo! 91 | edges: [PersonEdge]! 92 | } 93 | """ 94 | ) 95 | 96 | # A mock data store of people 97 | people_data = { 98 | "1": Person(id=1, firstName="John", lastName="Doe", age=21), 99 | "2": Person(id=2, firstName="Bob", lastName="Boberson", age=24), 100 | } 101 | 102 | # Instead of using Ariadne's QueryType, use the Relay-enabled 103 | # RelayQueryType class 104 | query = RelayQueryType() 105 | 106 | # resolve_node_query is provided as a resolver for Query.node() 107 | query.set_field("node", resolve_node_query) 108 | 109 | # Connection resolvers work exactly like standard Ariadne resolvers, 110 | # except they convert the returned value to a connection structure 111 | @query.connection("people") 112 | def resolve_people(*_): 113 | return list(people_data.values()) 114 | 115 | 116 | # Define the Node interface 117 | node = InterfaceType("Node") 118 | 119 | # Add a Node type resolver 120 | @node.type_resolver 121 | def resolve_node_type(obj, *_): 122 | return obj.__class__.__name__ 123 | 124 | 125 | # Instead of Ariadne's ObjectType, use the Relay-enabled 126 | # NodeObjectType class for types that implement Node 127 | person = NodeObjectType("Person") 128 | 129 | 130 | # Add an instance_resolver to define how an instance of 131 | # this type is retrieved, given an id 132 | @person.instance_resolver 133 | def resolve_person_instance(id, *_): 134 | return people_data.get(id) 135 | 136 | 137 | @person.field("fullName") 138 | def resolve_person_fullname(person, *_): 139 | return "%s %s" % (person.firstName, person.lastName) 140 | 141 | 142 | # Create executable GraphQL schema 143 | schema = make_executable_schema(type_defs, node, query, person) 144 | 145 | # Create an ASGI app using the schema, running in debug mode 146 | app = GraphQL(schema, debug=True) 147 | ``` 148 | 149 | 150 | ## Connection Factories 151 | 152 | The heavy lifting of generating a connection structure in a `RelayObjectType.connection()` 153 | is performed by the chosen factory. It is possible to specify a factory of your chosing 154 | by passing it in the call to `connection()`: 155 | ``` 156 | @query.connection("people", factory=CustomConnection) 157 | ``` 158 | 159 | ### ReferenceConnection 160 | The default that is used when `factory` is not overridden is `ReferenceConnection`. This 161 | implementation wraps `graphql_relay.connection_from_array_slice()` and provides the expected 162 | behavior of the Relay reference implementation. 163 | 164 | 165 | ### SnakeCaseConnection 166 | The `SnakeCaseConnection` factory provides equivalent functionality to `ReferenceConnection`, 167 | but returns a connection structure with snake-case field names. This is useful in conjunction 168 | with `ariadne.snake_case_fallback_resolvers`. 169 | 170 | 171 | ### ConnectionProxy 172 | The `ConnectionProxy` factory can be used to proxy an already-formed connection structure, 173 | for example a payload that was produced by an external GraphQL endpoint. It simply passes through 174 | the data untouched. 175 | 176 | 177 | ### Custom Factories 178 | Many deployments will benefit from customizing the connection factory. One example would be 179 | properly integrating a given ORM like Django. Other examples might be extending the functionality 180 | of connections, or customizing how cursors are formed. The `BaseConnection` and `SnakeCaseBaseConnection` 181 | classes can be useful for this purpose. 182 | 183 | 184 | ## Contributing 185 | Please see [CONTRIBUTING.md](CONTRIBUTING.md). 186 | -------------------------------------------------------------------------------- /tests/test_node.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from ariadne import InterfaceType, make_executable_schema, QueryType 4 | from graphql import graphql_sync 5 | from graphql_relay import to_global_id 6 | from graphql_relay.utils import base64 7 | 8 | from ariadne_relay import NodeInterfaceType, RelayObjectType, RelayQueryType 9 | from .conftest import Foo, Qux 10 | 11 | 12 | def test_node_instance_resolver( 13 | type_defs: str, 14 | foo_nodes: Dict[str, Foo], 15 | foo_type: RelayObjectType, 16 | query_type: RelayQueryType, 17 | node_query: str, 18 | node_interface_type: InterfaceType, 19 | ) -> None: 20 | schema = make_executable_schema( 21 | type_defs, 22 | foo_type, 23 | query_type, 24 | node_interface_type, 25 | ) 26 | for obj in foo_nodes.values(): 27 | type_name = obj.__class__.__name__ 28 | global_id = to_global_id(type_name, str(obj.id)) 29 | result = graphql_sync(schema, node_query, variable_values={"id": global_id}) 30 | assert result.errors is None 31 | assert result.data == { 32 | "node": { 33 | "__typename": type_name, 34 | "id": global_id, 35 | } 36 | } 37 | 38 | 39 | def test_node_interface_instance_resolver( 40 | type_defs: str, 41 | baz_interface_type: NodeInterfaceType, 42 | qux_nodes: Dict[str, Qux], 43 | qux_type: RelayObjectType, 44 | query_type: RelayQueryType, 45 | node_query: str, 46 | node_interface_type: InterfaceType, 47 | ) -> None: 48 | schema = make_executable_schema( 49 | type_defs, 50 | query_type, 51 | baz_interface_type, 52 | qux_type, 53 | node_interface_type, 54 | ) 55 | for obj in qux_nodes.values(): 56 | global_id = to_global_id("Baz", str(obj.id)) 57 | result = graphql_sync(schema, node_query, variable_values={"id": global_id}) 58 | assert result.errors is None 59 | assert result.data == { 60 | "node": { 61 | "__typename": obj.__class__.__name__, 62 | "id": global_id, 63 | } 64 | } 65 | # a NodeInterfaceType instance resolver does not 66 | # propagate to types that implement the interface 67 | invalid_global_id = to_global_id("Qux", str(obj.id)) 68 | result = graphql_sync( 69 | schema, 70 | node_query, 71 | variable_values={"id": invalid_global_id}, 72 | ) 73 | assert result.errors is None 74 | assert result.data == {"node": None} 75 | 76 | 77 | def test_node_typename_resolver( 78 | type_defs: str, 79 | baz_interface_type: NodeInterfaceType, 80 | qux_nodes: Dict[str, Qux], 81 | qux_type: RelayObjectType, 82 | query_type: RelayQueryType, 83 | qux_connection_query: str, 84 | ) -> None: 85 | schema = make_executable_schema(type_defs, query_type, baz_interface_type, qux_type) 86 | result = graphql_sync(schema, qux_connection_query) 87 | assert result.errors is None 88 | assert result.data == { 89 | "quxes": { 90 | "edges": [ 91 | { 92 | "node": { 93 | "__typename": obj.__class__.__name__, 94 | "id": to_global_id("Baz", str(obj.id)), 95 | }, 96 | } 97 | for obj in qux_nodes.values() 98 | ], 99 | }, 100 | } 101 | 102 | 103 | def test_non_node_typename( 104 | type_defs: str, 105 | query_type: QueryType, 106 | node_query: str, 107 | node_interface_type: InterfaceType, 108 | ) -> None: 109 | schema = make_executable_schema(type_defs, query_type, node_interface_type) 110 | global_id = to_global_id("Bar", "bar") 111 | result = graphql_sync(schema, node_query, variable_values={"id": global_id}) 112 | assert result.errors is None 113 | assert result.data == {"node": None} 114 | 115 | 116 | def test_invalid_encoding( 117 | type_defs: str, 118 | query_type: QueryType, 119 | node_query: str, 120 | node_interface_type: InterfaceType, 121 | ) -> None: 122 | schema = make_executable_schema(type_defs, query_type, node_interface_type) 123 | global_id = "invalid" 124 | result = graphql_sync(schema, node_query, variable_values={"id": global_id}) 125 | assert result.errors is None 126 | assert result.data == {"node": None} 127 | 128 | 129 | def test_missing_separator( 130 | type_defs: str, 131 | query_type: QueryType, 132 | node_query: str, 133 | node_interface_type: InterfaceType, 134 | ) -> None: 135 | schema = make_executable_schema(type_defs, query_type, node_interface_type) 136 | global_id = base64("foo") 137 | result = graphql_sync(schema, node_query, variable_values={"id": global_id}) 138 | assert result.errors is None 139 | assert result.data == {"node": None} 140 | 141 | 142 | def test_missing_typename( 143 | type_defs: str, 144 | query_type: QueryType, 145 | node_query: str, 146 | node_interface_type: InterfaceType, 147 | ) -> None: 148 | schema = make_executable_schema(type_defs, query_type, node_interface_type) 149 | global_id = base64(":bar") 150 | result = graphql_sync(schema, node_query, variable_values={"id": global_id}) 151 | assert result.errors is None 152 | assert result.data == {"node": None} 153 | 154 | 155 | def test_missing_id( 156 | type_defs: str, 157 | query_type: QueryType, 158 | node_query: str, 159 | node_interface_type: InterfaceType, 160 | ) -> None: 161 | schema = make_executable_schema(type_defs, query_type, node_interface_type) 162 | global_id = base64("foo:") 163 | result = graphql_sync(schema, node_query, variable_values={"id": global_id}) 164 | assert result.errors is None 165 | assert result.data == {"node": None} 166 | 167 | 168 | def test_empty_global_id( 169 | type_defs: str, 170 | query_type: QueryType, 171 | node_query: str, 172 | node_interface_type: InterfaceType, 173 | ) -> None: 174 | schema = make_executable_schema(type_defs, query_type, node_interface_type) 175 | global_id = "" 176 | result = graphql_sync(schema, node_query, variable_values={"id": global_id}) 177 | assert result.errors is None 178 | assert result.data == {"node": None} 179 | -------------------------------------------------------------------------------- /ariadne_relay/node.py: -------------------------------------------------------------------------------- 1 | from inspect import isawaitable 2 | from typing import Any, Awaitable, Callable, cast, Optional, Tuple, Union 3 | 4 | from ariadne.types import Resolver 5 | from graphql import ( 6 | default_field_resolver, 7 | GraphQLNamedType, 8 | GraphQLObjectType, 9 | GraphQLResolveInfo, 10 | ) 11 | from graphql_relay import from_global_id, to_global_id 12 | 13 | from .interfaces import RelayInterfaceType 14 | from .objects import RelayObjectType 15 | from .utils import is_coroutine_callable 16 | 17 | NodeIdAwaitable = Callable[..., Awaitable[str]] 18 | NodeIdCallable = Callable[..., str] 19 | NodeIdResolver = Union[NodeIdAwaitable, NodeIdCallable] 20 | NodeInstanceAwaitable = Callable[..., Awaitable[Any]] 21 | NodeInstanceCallable = Callable[..., Any] 22 | NodeInstanceResolver = Union[NodeInstanceAwaitable, NodeInstanceCallable] 23 | NodeTypenameAwaitable = Callable[..., Awaitable[str]] 24 | NodeTypenameCallable = Callable[..., str] 25 | NodeTypenameResolver = Union[NodeTypenameAwaitable, NodeTypenameCallable] 26 | 27 | ID_RESOLVER = "ariadne_relay_node_id_resolver" 28 | INSTANCE_RESOLVER = "ariadne_relay_node_instance_resolver" 29 | TYPENAME_RESOLVER = "ariadne_relay_node_typename_resolver" 30 | 31 | 32 | async def resolve_node_query( 33 | _: None, 34 | info: GraphQLResolveInfo, 35 | *, 36 | id: str, # noqa: A002 37 | ) -> Any: 38 | instance_resolver_and_node_id = _get_instance_resolver_and_node_id(info, id) 39 | if instance_resolver_and_node_id: 40 | instance_resolver, node_id = instance_resolver_and_node_id 41 | node_instance = instance_resolver(node_id, info) 42 | if isawaitable(node_instance): 43 | node_instance = await node_instance 44 | return node_instance 45 | return None 46 | 47 | 48 | def resolve_node_query_sync( 49 | _: None, 50 | info: GraphQLResolveInfo, 51 | *, 52 | id: str, # noqa: A002 53 | ) -> Any: 54 | instance_resolver_and_node_id = _get_instance_resolver_and_node_id(info, id) 55 | if instance_resolver_and_node_id: 56 | instance_resolver, node_id = instance_resolver_and_node_id 57 | return instance_resolver(node_id, info) 58 | return None 59 | 60 | 61 | class NodeType: 62 | name: str 63 | _resolve_id: Optional[NodeIdResolver] 64 | _resolve_instance: Optional[NodeInstanceResolver] 65 | _resolve_typename: Optional[NodeTypenameResolver] 66 | 67 | def bind_node_resolvers_to_graphql_type( 68 | self, graphql_type: GraphQLObjectType, replace_existing: bool = True 69 | ) -> None: 70 | if "id" not in graphql_type.fields: 71 | raise ValueError(f"Field id is not defined on type {self.name}") 72 | if graphql_type.fields["id"].resolve is None or replace_existing: 73 | if is_coroutine_callable(self._resolve_typename) or is_coroutine_callable( 74 | self._resolve_id 75 | ): 76 | graphql_type.fields["id"].resolve = self._resolve_node_id_field 77 | else: 78 | graphql_type.fields["id"].resolve = self._resolve_node_id_field_sync 79 | if self._resolve_id is not None: 80 | _set_extension( 81 | graphql_type, 82 | ID_RESOLVER, 83 | self._resolve_id, 84 | replace_existing, 85 | ) 86 | if self._resolve_instance is not None and graphql_type.name == self.name: 87 | _set_extension( 88 | graphql_type, 89 | INSTANCE_RESOLVER, 90 | self._resolve_instance, 91 | replace_existing, 92 | ) 93 | if self._resolve_typename is not None: 94 | _set_extension( 95 | graphql_type, 96 | TYPENAME_RESOLVER, 97 | self._resolve_typename, 98 | replace_existing, 99 | ) 100 | 101 | async def _resolve_node_id_field(self, obj: Any, info: GraphQLResolveInfo) -> str: 102 | node_typename = self._resolve_node_typename(obj, info) 103 | if isawaitable(node_typename): 104 | node_typename = await cast(Awaitable[str], node_typename) 105 | node_id = self._resolve_node_id(obj, info) 106 | if isawaitable(node_id): 107 | node_id = await cast(Awaitable[str], node_id) 108 | return to_global_id(cast(str, node_typename), cast(str, node_id)) 109 | 110 | def _resolve_node_id_field_sync(self, obj: Any, info: GraphQLResolveInfo) -> str: 111 | node_typename = cast(str, self._resolve_node_typename(obj, info)) 112 | node_id = cast(str, self._resolve_node_id(obj, info)) 113 | return to_global_id(node_typename, node_id) 114 | 115 | def _resolve_node_id( 116 | self, 117 | obj: Any, 118 | info: GraphQLResolveInfo, 119 | ) -> Union[str, Awaitable[str]]: 120 | resolve_id = cast( 121 | Optional[NodeIdResolver], 122 | _get_extension(info.parent_type, ID_RESOLVER), 123 | ) 124 | if resolve_id: 125 | return resolve_id(obj, info) 126 | return cast(str, default_field_resolver(obj, info)) 127 | 128 | def _resolve_node_typename( 129 | self, 130 | obj: Any, 131 | info: GraphQLResolveInfo, 132 | ) -> Union[str, Awaitable[str]]: 133 | resolve_typename = cast( 134 | Optional[NodeTypenameResolver], 135 | _get_extension(info.parent_type, TYPENAME_RESOLVER), 136 | ) 137 | if resolve_typename: 138 | return resolve_typename(obj, info) 139 | return info.parent_type.name 140 | 141 | def set_id_resolver(self, id_resolver: NodeIdResolver) -> NodeIdResolver: 142 | self._resolve_id = id_resolver 143 | return id_resolver 144 | 145 | def set_instance_resolver( 146 | self, instance_resolver: NodeInstanceResolver 147 | ) -> NodeInstanceResolver: 148 | self._resolve_instance = instance_resolver 149 | return instance_resolver 150 | 151 | def set_typename_resolver( 152 | self, 153 | typename_resolver: NodeTypenameResolver, 154 | ) -> NodeTypenameResolver: 155 | self._resolve_typename = typename_resolver 156 | return typename_resolver 157 | 158 | # Alias resolvers for consistent decorator API 159 | id_resolver = set_id_resolver 160 | instance_resolver = set_instance_resolver 161 | typename_resolver = set_typename_resolver 162 | 163 | 164 | def _get_extension(graphql_type: GraphQLNamedType, name: str) -> Any: 165 | if not isinstance(graphql_type.extensions, dict): 166 | return None 167 | return graphql_type.extensions.get(name) 168 | 169 | 170 | def _set_extension( 171 | graphql_type: GraphQLNamedType, 172 | name: str, 173 | value: Any, 174 | replace_existing: bool, 175 | ) -> None: 176 | graphql_type.extensions = graphql_type.extensions or {} 177 | if name not in graphql_type.extensions or replace_existing: 178 | graphql_type.extensions[name] = value 179 | 180 | 181 | def _get_instance_resolver_and_node_id( 182 | info: GraphQLResolveInfo, 183 | raw_id: str, 184 | ) -> Optional[Tuple[NodeInstanceResolver, str]]: 185 | node_type_name, node_id = from_global_id(raw_id) 186 | node_type = info.schema.type_map.get(node_type_name) 187 | if node_type: 188 | instance_resolver = _get_extension(node_type, INSTANCE_RESOLVER) 189 | if instance_resolver: 190 | return instance_resolver, node_id 191 | return None 192 | 193 | 194 | class NodeObjectType(NodeType, RelayObjectType): 195 | def __init__( 196 | self, 197 | name: str, 198 | *, 199 | id_resolver: Optional[NodeIdResolver] = None, 200 | instance_resolver: Optional[NodeInstanceResolver] = None, 201 | typename_resolver: Optional[NodeTypenameResolver] = None, 202 | **kwargs: Any, 203 | ) -> None: 204 | super().__init__(name) 205 | self._resolve_id = id_resolver 206 | self._resolve_instance = instance_resolver 207 | self._resolve_typename = typename_resolver 208 | 209 | def bind_resolvers_to_graphql_type( 210 | self, 211 | graphql_type: GraphQLObjectType, 212 | replace_existing: bool = True, 213 | ) -> None: 214 | super().bind_resolvers_to_graphql_type(graphql_type, replace_existing) 215 | self.bind_node_resolvers_to_graphql_type(graphql_type, replace_existing) 216 | 217 | 218 | class NodeInterfaceType(NodeType, RelayInterfaceType): 219 | def __init__( 220 | self, 221 | name: str, 222 | type_resolver: Optional[Resolver] = None, 223 | *, 224 | id_resolver: Optional[NodeIdResolver] = None, 225 | instance_resolver: Optional[NodeInstanceResolver] = None, 226 | typename_resolver: Optional[NodeTypenameResolver] = None, 227 | **kwargs: Any, 228 | ) -> None: 229 | super().__init__(name, type_resolver) 230 | self._resolve_id = id_resolver 231 | self._resolve_instance = instance_resolver 232 | self._resolve_typename = typename_resolver 233 | 234 | def bind_resolvers_to_graphql_type( 235 | self, 236 | graphql_type: GraphQLObjectType, 237 | replace_existing: bool = True, 238 | ) -> None: 239 | super().bind_resolvers_to_graphql_type(graphql_type, replace_existing) 240 | self.bind_node_resolvers_to_graphql_type(graphql_type, replace_existing) 241 | --------------------------------------------------------------------------------