├── tests ├── __init__.py ├── _compat.py ├── sdl │ └── Query.graphql ├── conftest.py ├── test_tartiflette_app.py ├── _utils.py ├── resolvers.py ├── test_context.py ├── test_graphiql.py ├── test_mount.py ├── test_subscription.py └── test_graphql_api.py ├── src └── tartiflette_asgi │ ├── py.typed │ ├── _subscriptions │ ├── __init__.py │ ├── constants.py │ ├── impl.py │ └── protocol.py │ ├── __init__.py │ ├── _errors.py │ ├── _middleware.py │ ├── _datastructures.py │ ├── _app.py │ ├── _endpoints.py │ └── graphiql.html ├── pytest.ini ├── MANIFEST.in ├── img ├── graphiql.png ├── graphiql-custom.png ├── tartiflette-asgi.png └── graphiql-subscriptions.png ├── scripts ├── serve ├── test ├── build ├── check ├── install ├── lint └── publish ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── ci.yaml ├── .gitignore ├── requirements.txt ├── setup.cfg ├── mkdocs.yml ├── LICENSE ├── docs ├── faq.md ├── index.md ├── api.md └── usage.md ├── CONTRIBUTING.md ├── setup.py ├── README.md └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src 2 | include README.md 3 | include CHANGELOG.md 4 | include LICENSE 5 | -------------------------------------------------------------------------------- /img/graphiql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tartiflette/tartiflette-asgi/HEAD/img/graphiql.png -------------------------------------------------------------------------------- /img/graphiql-custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tartiflette/tartiflette-asgi/HEAD/img/graphiql-custom.png -------------------------------------------------------------------------------- /img/tartiflette-asgi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tartiflette/tartiflette-asgi/HEAD/img/tartiflette-asgi.png -------------------------------------------------------------------------------- /img/graphiql-subscriptions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tartiflette/tartiflette-asgi/HEAD/img/graphiql-subscriptions.png -------------------------------------------------------------------------------- /src/tartiflette_asgi/_subscriptions/__init__.py: -------------------------------------------------------------------------------- 1 | from .impl import GraphQLWSProtocol 2 | 3 | __all__ = ["GraphQLWSProtocol"] 4 | -------------------------------------------------------------------------------- /scripts/serve: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ] ; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | 8 | set -x 9 | 10 | ${PREFIX}mkdocs serve 11 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/__init__.py: -------------------------------------------------------------------------------- 1 | from ._app import TartifletteApp 2 | from ._datastructures import GraphiQL, Subscriptions 3 | 4 | __version__ = "0.12.0" 5 | __all__ = ["GraphiQL", "Subscriptions", "TartifletteApp"] 6 | -------------------------------------------------------------------------------- /tests/_compat.py: -------------------------------------------------------------------------------- 1 | try: 2 | from contextlib import asynccontextmanager 3 | except ImportError: # pragma: no cover 4 | # Python 3.6 5 | from async_generator import asynccontextmanager # type: ignore # noqa: F401 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "09:00" 8 | timezone: Europe/Paris 9 | open-pull-requests-limit: 10 10 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ] ; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | 8 | set -x 9 | 10 | if [[ -z $GITHUB_ACTIONS ]]; then 11 | scripts/check 12 | fi 13 | 14 | ${PREFIX}pytest ${@} 15 | -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ] ; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | 8 | set -x 9 | 10 | ${PREFIX}python setup.py build sdist bdist_wheel 11 | ${PREFIX}twine check dist/* 12 | rm -r build 13 | 14 | ${PREFIX}mkdocs build 15 | -------------------------------------------------------------------------------- /tests/sdl/Query.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | hello(name: String): String 3 | whoami: String 4 | foo: String 5 | dog(id: Int!): Dog 6 | } 7 | 8 | type Dog { 9 | id: Int! 10 | name: String! 11 | nickname: String 12 | } 13 | 14 | type Subscription { 15 | dogAdded: Dog 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | venv*/ 3 | bin/ 4 | build/ 5 | develop-eggs/ 6 | dist/ 7 | eggs/ 8 | lib/ 9 | lib64/ 10 | parts/ 11 | sdist/ 12 | var/ 13 | *.egg-info/ 14 | .installed.cfg 15 | *.egg 16 | *.eggs 17 | .mypy_cache/ 18 | .vscode 19 | .pytest_cache/ 20 | debug/ 21 | .DS_Store 22 | *.idea/ 23 | site/ 24 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from tartiflette import Engine 5 | 6 | 7 | # NOTE: must be session-scoped to prevent redefining GraphQL types. 8 | @pytest.fixture(scope="session") 9 | def engine() -> Engine: 10 | sdl = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sdl") 11 | return Engine(sdl, modules=["tests.resolvers"]) 12 | -------------------------------------------------------------------------------- /scripts/check: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ] ; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | 8 | export SOURCE_FILES="src tests setup.py" 9 | 10 | set -x 11 | 12 | ${PREFIX}black --check --diff --target-version=py36 $SOURCE_FILES 13 | ${PREFIX}mypy $SOURCE_FILES 14 | ${PREFIX}flake8 $SOURCE_FILES 15 | ${PREFIX}isort --check --diff $SOURCE_FILES 16 | -------------------------------------------------------------------------------- /scripts/install: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | 3 | if [ -z $GITHUB_ACTIONS ]; then 4 | python -m venv venv 5 | export PREFIX="venv/bin/" 6 | else 7 | export PREFIX="" 8 | # Tartiflette requires these binaries to be installed, but 9 | # they don't come installed on Actions' Ubuntu. 10 | sudo apt-get install cmake flex bison 11 | fi 12 | 13 | ${PREFIX}pip install -U pip 14 | ${PREFIX}pip install -r requirements.txt 15 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/_errors.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import typing 3 | 4 | 5 | def _format_error(error: typing.Any) -> dict: 6 | try: 7 | return ast.literal_eval(str(error)) 8 | except ValueError: 9 | return {"message": "Internal Server Error"} 10 | 11 | 12 | def format_errors(errors: typing.Sequence[typing.Any]) -> typing.List[dict]: 13 | return [_format_error(error) for error in errors] 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | # Packaging 4 | twine 5 | wheel 6 | 7 | # Testing 8 | asgi-lifespan 9 | async-generator; python_version<'3.7' 10 | autoflake 11 | black==22.3.0 12 | flake8 13 | flake8-bugbear 14 | flake8-comprehensions 15 | httpx==0.21.* 16 | isort==5.* 17 | mkdocs 18 | mkdocs-material 19 | mypy 20 | pyee>=6,<8 21 | pytest 22 | pytest-asyncio 23 | requests # Required by the Starlette test client. 24 | seed-isort-config 25 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | export SOURCE_FILES="src tests setup.py" 4 | export PREFIX="" 5 | if [ -d 'venv' ] ; then 6 | export PREFIX="venv/bin/" 7 | fi 8 | 9 | set -x 10 | 11 | ${PREFIX}autoflake --in-place --recursive $SOURCE_FILES 12 | ${PREFIX}seed-isort-config --application-directories=tartiflette_asgi 13 | ${PREFIX}isort $SOURCE_FILES 14 | ${PREFIX}black --target-version=py36 $SOURCE_FILES 15 | 16 | scripts/check 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | ignore = W503, E203, B305 6 | max-line-length = 88 7 | 8 | [mypy] 9 | disallow_untyped_defs = True 10 | ignore_missing_imports = True 11 | 12 | [tool:isort] 13 | profile = black 14 | known_first_party = tartiflette_asgi,tests 15 | known_third_party = asgi_lifespan,httpx,pyee,pytest,setuptools,starlette,tartiflette 16 | 17 | [tool:pytest] 18 | addopts = 19 | -W error 20 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/_subscriptions/constants.py: -------------------------------------------------------------------------------- 1 | class GQL: 2 | # Client -> Server message types. 3 | CONNECTION_INIT = "connection_init" 4 | START = "start" 5 | STOP = "stop" 6 | CONNECTION_TERMINATE = "connection_terminate" 7 | 8 | # Server -> Client message types. 9 | CONNECTION_ERROR = "connection_error" 10 | CONNECTION_ACK = "connection_ack" 11 | DATA = "data" 12 | ERROR = "error" 13 | COMPLETE = "complete" 14 | CONNECTION_KEEP_ALIVE = "ka" 15 | -------------------------------------------------------------------------------- /tests/test_tartiflette_app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tartiflette import Engine 3 | 4 | from tartiflette_asgi import TartifletteApp 5 | 6 | from ._utils import get_client 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_path(engine: Engine) -> None: 11 | app = TartifletteApp(engine=engine, path="/graphql") 12 | 13 | async with get_client(app) as client: 14 | response = await client.get("/") 15 | assert response.status_code == 404 16 | response = await client.get("/graphql?query={ hello }") 17 | assert response.status_code == 200 18 | assert response.json() == {"data": {"hello": "Hello stranger"}} 19 | -------------------------------------------------------------------------------- /scripts/publish: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | 3 | export PREFIX="" 4 | if [ -d 'venv' ] ; then 5 | export PREFIX="venv/bin/" 6 | fi 7 | 8 | if [ ! -z "$GITHUB_ACTIONS" ]; then 9 | git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" 10 | git config --local user.name "GitHub Action" 11 | 12 | VERSION=`grep __version__ ${VERSION_FILE} | grep -o '[0-9][^"]*'` 13 | 14 | if [ "refs/tags/${VERSION}" != "${GITHUB_REF}" ] ; then 15 | echo "GitHub Ref '${GITHUB_REF}' did not match package version '${VERSION}'" 16 | exit 1 17 | fi 18 | fi 19 | 20 | set -x 21 | 22 | ${PREFIX}twine upload dist/* 23 | ${PREFIX}mkdocs gh-deploy --force 24 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/_middleware.py: -------------------------------------------------------------------------------- 1 | from starlette.requests import HTTPConnection 2 | from starlette.types import ASGIApp, Receive, Scope, Send 3 | 4 | from ._datastructures import GraphQLConfig 5 | 6 | 7 | class GraphQLMiddleware: 8 | def __init__(self, app: ASGIApp, config: GraphQLConfig): 9 | self.app = app 10 | self.config = config 11 | 12 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 13 | scope["graphql"] = self.config 14 | await self.app(scope, receive, send) 15 | 16 | 17 | def get_graphql_config(conn: HTTPConnection) -> GraphQLConfig: 18 | config = conn["graphql"] 19 | assert isinstance(config, GraphQLConfig) 20 | return config 21 | -------------------------------------------------------------------------------- /tests/_utils.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import httpx 4 | from asgi_lifespan import LifespanManager 5 | from pyee import AsyncIOEventEmitter 6 | 7 | from ._compat import asynccontextmanager 8 | 9 | 10 | @asynccontextmanager 11 | async def get_client(app: typing.Callable) -> typing.AsyncIterator: 12 | async with LifespanManager(app): 13 | async with httpx.AsyncClient(app=app, base_url="http://testserver/") as client: 14 | yield client 15 | 16 | 17 | def omit_none(dct: dict) -> dict: 18 | return {key: value for key, value in dct.items() if value is not None} 19 | 20 | 21 | PubSub = AsyncIOEventEmitter 22 | pubsub = PubSub() 23 | 24 | 25 | class Dog(typing.NamedTuple): 26 | id: int 27 | name: str 28 | nickname: typing.Optional[str] = None 29 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: tartiflette-asgi 2 | site_description: ASGI support for the Tartiflette Python GraphQL engine 3 | 4 | theme: 5 | name: material 6 | palette: 7 | primary: "black" 8 | accent: "orange" 9 | 10 | repo_name: tartiflette/tartiflette-asgi 11 | repo_url: https://github.com/tartiflette/tartiflette-asgi 12 | edit_uri: "" 13 | 14 | nav: 15 | - Introduction: "index.md" 16 | - User Guide: "usage.md" 17 | - API Reference: "api.md" 18 | - FAQ: "faq.md" 19 | - Contributing: "https://github.com/tartiflette/tartiflette-asgi/tree/master/CONTRIBUTING.md" 20 | - Changelog: "https://github.com/tartiflette/tartiflette-asgi/tree/master/CHANGELOG.md" 21 | 22 | markdown_extensions: 23 | - admonition 24 | - codehilite: 25 | guess_lang: false 26 | - pymdownx.superfences 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | publish: 10 | name: Publish release 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.8 18 | - name: Cache dependencies 19 | uses: actions/cache@v1 20 | with: 21 | path: ~/.cache/pip 22 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} 23 | restore-keys: | 24 | ${{ runner.os }}-pip- 25 | - name: Install dependencies 26 | run: scripts/install 27 | - name: Build package and docs 28 | run: scripts/build 29 | - name: Publish to PyPI & deploy docs 30 | run: scripts/publish 31 | env: 32 | TWINE_USERNAME: __token__ 33 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Florimond Manca 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/resolvers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import typing 3 | from queue import Empty, Queue 4 | 5 | from starlette.requests import Request 6 | from tartiflette import Resolver, Subscription 7 | 8 | from ._utils import Dog, PubSub 9 | 10 | 11 | @Resolver("Query.hello") 12 | async def hello(parent: typing.Any, args: dict, context: dict, info: dict) -> str: 13 | name = args.get("name", "stranger") 14 | return "Hello " + name 15 | 16 | 17 | @Resolver("Query.whoami") 18 | async def resolve_whoami( 19 | parent: typing.Any, args: dict, context: dict, info: dict 20 | ) -> str: 21 | request: Request = context["req"] 22 | user = request.state.user 23 | return "a mystery" if user is None else user 24 | 25 | 26 | @Resolver("Query.foo") 27 | async def resolve_foo(parent: typing.Any, args: dict, context: dict, info: dict) -> str: 28 | get_foo = context.get("get_foo", lambda: "default") 29 | return get_foo() 30 | 31 | 32 | @Subscription("Subscription.dogAdded") 33 | async def on_dog_added( 34 | parent: typing.Any, args: dict, ctx: dict, info: dict 35 | ) -> typing.AsyncIterator[dict]: 36 | pubsub: PubSub = ctx["pubsub"] 37 | queue: Queue = Queue() 38 | 39 | @pubsub.on("dog_added") 40 | def on_dog(dog: Dog) -> None: 41 | queue.put(dog) 42 | 43 | while True: 44 | try: 45 | dog = queue.get_nowait() 46 | except Empty: 47 | await asyncio.sleep(0.01) 48 | continue 49 | else: 50 | queue.task_done() 51 | if dog is None: 52 | break 53 | yield {"dogAdded": dog._asdict()} 54 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | name: Checks 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.8 19 | - name: Cache dependencies 20 | uses: actions/cache@v1 21 | with: 22 | path: ~/.cache/pip 23 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} 24 | restore-keys: | 25 | ${{ runner.os }}-pip- 26 | - name: Install dependencies 27 | run: scripts/install 28 | - name: Run checks 29 | run: scripts/check 30 | - name: Build package and docs 31 | run: scripts/build 32 | 33 | test: 34 | name: Test (Python ${{ matrix.python-version }}) 35 | runs-on: ubuntu-latest 36 | 37 | strategy: 38 | matrix: 39 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10"] 40 | 41 | steps: 42 | - uses: actions/checkout@v2 43 | - uses: actions/setup-python@v2 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | - name: Cache dependencies 47 | uses: actions/cache@v1 48 | with: 49 | path: ~/.cache/pip 50 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} 51 | restore-keys: | 52 | ${{ runner.os }}-pip- 53 | - name: Install dependencies 54 | run: scripts/install 55 | - name: Run tests 56 | run: scripts/test 57 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## What is ASGI? 4 | 5 | ASGI provides a standard interface between async-capable Python web servers, frameworks, and applications. An ASGI application is a callable with the following signature: 6 | 7 | ```python 8 | async def app(scope, receive, send) -> None: 9 | ... 10 | ``` 11 | 12 | For more information, see the [ASGI documentation](https://asgi.readthedocs.io/en/latest/) and this list of [publications about ASGI](https://github.com/florimondmanca/awesome-asgi#publications). 13 | 14 | ## Do I need to learn GraphQL/Tartiflette to use this package? 15 | 16 | **Yes**: once you've got the `TartifletteApp` ASGI app up and running, you're in Tartiflette territory. 17 | 18 | Here are some resources to get you started: 19 | 20 | - [Tartiflette tutorial](https://tartiflette.io/docs/tutorial/getting-started) 21 | - [Introduction to GraphQL](https://graphql.org/learn/) 22 | - [Tartiflette API reference](https://tartiflette.io/docs/api/engine) 23 | 24 | ## Does this package ship with Tartiflette? 25 | 26 | **Yes**. Everything is included, which allows you to start building your GraphQL API right away. See also [Installation](#installation). 27 | 28 | ## What is the role of Starlette? 29 | 30 | `tartiflette-asgi` uses Starlette as a lightweight ASGI toolkit: internally, it uses Starlette's request and response classes, and some other components. 31 | 32 | Luckily, this does not require your applications to use Starlette at all. 33 | 34 | For example, if you are [submounting your GraphQL app](#submounting-on-another-asgi-app) on an app built with an async web framework, this framework does not need to use Starlette — it just needs to speak ASGI. 35 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/_datastructures.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json 3 | import os 4 | import string 5 | import typing 6 | 7 | from tartiflette import Engine 8 | 9 | _GRAPHIQL_TEMPLATE = os.path.join(os.path.dirname(__file__), "graphiql.html") 10 | 11 | 12 | def _optional(value: typing.Optional[str]) -> str: 13 | return value if value is not None else "" 14 | 15 | 16 | class Subscriptions: 17 | def __init__(self, *, path: str) -> None: 18 | self.path = path 19 | 20 | 21 | class GraphiQL: 22 | def __init__( 23 | self, 24 | *, 25 | path: str = None, 26 | template: str = None, 27 | default_query: str = "", 28 | default_variables: dict = None, 29 | default_headers: dict = None, 30 | ): 31 | if template is None: 32 | with open(_GRAPHIQL_TEMPLATE, encoding="utf-8") as f: 33 | template = f.read() 34 | self.path = path 35 | self.template = string.Template(template) 36 | self.default_query = inspect.cleandoc(default_query) 37 | self.default_variables = default_variables or {} 38 | self.default_headers = default_headers or {} 39 | 40 | def render_template( 41 | self, graphql_endpoint: str, subscriptions_endpoint: typing.Optional[str] 42 | ) -> str: 43 | return self.template.substitute( 44 | endpoint=graphql_endpoint, 45 | subscriptions_endpoint=_optional(subscriptions_endpoint), 46 | default_query=self.default_query, 47 | default_variables=json.dumps(self.default_variables), 48 | default_headers=json.dumps(self.default_headers), 49 | ) 50 | 51 | 52 | class GraphQLConfig(typing.NamedTuple): 53 | engine: Engine 54 | context: dict 55 | graphiql: typing.Optional[GraphiQL] 56 | path: str 57 | subscriptions: typing.Optional[Subscriptions] 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | Thank you for your interest in contributing to this project! Here are some tips to get you started. 4 | 5 | ## Getting started 6 | 7 | - Consider [opening an issue](https://github.com/tartiflette/tartiflette-asgi/issues/new) if the change you are proposing is not trivial. :+1: 8 | - Fork this repo on GitHub, then clone it to your machine. 9 | - Install dependencies: 10 | 11 | ```bash 12 | scripts/install 13 | ``` 14 | 15 | - When you're ready to have your work reviewed, [open a pull request!](https://github.com/tartiflette/tartiflette-asgi/compare) :rocket: 16 | 17 | ## Testing and linting 18 | 19 | You can run code auto-formatting using: 20 | 21 | ```shell 22 | scripts/lint 23 | ``` 24 | 25 | To run the test suite and code checks, run: 26 | 27 | ```shell 28 | scripts/test 29 | ``` 30 | 31 | If this step passes, you should be on track to pass CI. 32 | 33 | You can run code checks separately using: 34 | 35 | ```shell 36 | scripts/check 37 | ``` 38 | 39 | ## Documentation 40 | 41 | Documentation pages are located in the `docs/` directory. 42 | 43 | If you'd like to preview changes, you can run the documentation site locally using: 44 | 45 | ```shell 46 | scripts/serve 47 | ``` 48 | 49 | ## Notes to maintainers 50 | 51 | To make a new release: 52 | 53 | - Create a PR with the following: 54 | - Bump the package version by editing `__version__` in `__init__.py`. 55 | - Update the changelog with any relevant PRs merged since the last version: bug fixes, new features, changes, deprecations, removals. 56 | - Get the PR reviewed and merged. 57 | - Once the release PR is reviewed and merged, create a new release on the GitHub UI, including: 58 | - Tag version, like `0.11.0`. 59 | - Release title, `Version 0.11.0`. 60 | - Description copied from the changelog. 61 | - Once created, the release tag will trigger a 'publish' job on CI, automatically pushing the new version to PyPI. 62 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/_subscriptions/impl.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import typing 3 | 4 | from starlette.websockets import WebSocket 5 | from tartiflette import Engine 6 | 7 | from . import protocol 8 | 9 | 10 | class GraphQLWSProtocol(protocol.GraphQLWSProtocol): 11 | def __init__(self, websocket: WebSocket, engine: Engine, context: dict): 12 | super().__init__() 13 | self.websocket = websocket 14 | self.engine = engine 15 | self.context = context 16 | self.tasks: typing.Set[asyncio.Task] = set() 17 | 18 | # Concurrency implementation. 19 | 20 | def schedule(self, coro: typing.Coroutine) -> None: 21 | loop = asyncio.get_event_loop() 22 | self.tasks.add(loop.create_task(coro)) 23 | 24 | async def on_disconnect(self, close_code: int) -> None: 25 | await super().on_disconnect(close_code) 26 | for task in self.tasks: 27 | task.cancel() 28 | 29 | # WebSocket implementation. 30 | 31 | async def send_json(self, message: typing.Any) -> None: 32 | await self.websocket.send_json(message) 33 | 34 | async def close(self, close_code: int) -> None: 35 | await self.websocket.close(close_code) 36 | 37 | # GraphQL engine implementation. 38 | 39 | def get_subscription( 40 | self, opid: str, payload: protocol.Payload 41 | ) -> protocol.Subscription: 42 | context = {**payload.get("context", {}), **self.context} 43 | aiterator = self.engine.subscribe( 44 | query=payload["query"], 45 | variables=payload.get("variables"), 46 | operation_name=payload.get("operationName"), 47 | context=context, 48 | ) 49 | 50 | # `tartiflette` type hints say it returns an `AsyncIterable` (doesn't 51 | # include `aclose`), but it is actually a full-fledged `AsyncGenerator` 52 | # which we want to be closing at some point. 53 | agen = typing.cast( 54 | typing.AsyncGenerator[typing.Dict[str, typing.Any], None], aiterator 55 | ) 56 | 57 | return protocol.Subscription(agen) 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | from pathlib import Path 6 | 7 | from setuptools import find_packages, setup 8 | 9 | 10 | def get_version(package: str) -> str: 11 | """Return package version as listed in `__version__` in `__init__.py`.""" 12 | version = Path("src", package, "__init__.py").read_text() 13 | match = re.search("__version__ = ['\"]([^'\"]+)['\"]", version) 14 | assert match is not None 15 | return match.group(1) 16 | 17 | 18 | def get_long_description() -> str: 19 | with open("README.md", encoding="utf8") as readme: 20 | with open("CHANGELOG.md", encoding="utf8") as changelog: 21 | return readme.read() + "\n\n" + changelog.read() 22 | 23 | 24 | setup( 25 | name="tartiflette-asgi", 26 | version=get_version("tartiflette_asgi"), 27 | author="Florimond Manca", 28 | author_email="florimond.manca@gmail.com", 29 | description="ASGI support for the Tartiflette Python GraphQL engine", 30 | long_description=get_long_description(), 31 | long_description_content_type="text/markdown", 32 | url="https://github.com/tartiflette/tartiflette-asgi", 33 | packages=find_packages("src"), 34 | package_dir={"": "src"}, 35 | include_package_data=True, 36 | zip_safe=False, 37 | install_requires=[ 38 | "starlette>=0.13,<1.0", 39 | "tartiflette>=1.0,<1.5", 40 | "typing-extensions; python_version<'3.8'", 41 | ], 42 | python_requires=">=3.6", 43 | # https://pypi.org/pypi?%3Aaction=list_classifiers 44 | license="MIT", 45 | classifiers=[ 46 | "Development Status :: 4 - Beta", 47 | "Operating System :: OS Independent", 48 | "Intended Audience :: Developers", 49 | "Topic :: Software Development :: Libraries", 50 | "Programming Language :: Python :: 3 :: Only", 51 | "Programming Language :: Python :: 3.6", 52 | "Programming Language :: Python :: 3.7", 53 | "Programming Language :: Python :: 3.8", 54 | "Programming Language :: Python :: 3.9", 55 | "Programming Language :: Python :: 3.10", 56 | ], 57 | ) 58 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pytest 4 | from starlette.applications import Starlette 5 | from starlette.middleware import Middleware 6 | from starlette.middleware.base import BaseHTTPMiddleware 7 | from starlette.requests import Request 8 | from starlette.responses import Response 9 | from starlette.routing import Mount 10 | from tartiflette import Engine 11 | 12 | from tartiflette_asgi import TartifletteApp 13 | 14 | from ._utils import get_client 15 | 16 | 17 | @pytest.mark.asyncio 18 | @pytest.mark.parametrize( 19 | "authorization, expected_user", [("", "a mystery"), ("Bearer 123", "Jane")] 20 | ) 21 | async def test_access_request_from_graphql_context( 22 | engine: Engine, 23 | authorization: str, 24 | expected_user: str, 25 | ) -> None: 26 | class FakeAuthMiddleware(BaseHTTPMiddleware): 27 | async def dispatch( 28 | self, request: Request, call_next: typing.Callable 29 | ) -> Response: 30 | request.state.user = ( 31 | "Jane" if request.headers["authorization"] == "Bearer 123" else None 32 | ) 33 | return await call_next(request) 34 | 35 | graphql = TartifletteApp(engine=engine) 36 | app = Starlette( 37 | routes=[Mount("/", graphql)], 38 | middleware=[Middleware(FakeAuthMiddleware)], 39 | on_startup=[graphql.startup], 40 | ) 41 | 42 | async with get_client(app) as client: 43 | # See `tests/resolvers.py` for the `whoami` resolver. 44 | response = await client.post( 45 | "/", 46 | json={"query": "{ whoami }"}, 47 | headers={"Authorization": authorization}, 48 | ) 49 | 50 | assert response.status_code == 200 51 | assert response.json() == {"data": {"whoami": expected_user}} 52 | 53 | 54 | @pytest.mark.asyncio 55 | @pytest.mark.parametrize("context", (None, {"get_foo": lambda: "bar"})) 56 | async def test_extra_context(engine: Engine, context: typing.Optional[dict]) -> None: 57 | app = TartifletteApp(engine=engine, context=context) 58 | 59 | async with get_client(app) as client: 60 | response = await client.post("/", json={"query": "{ foo }"}) 61 | 62 | assert response.status_code == 200 63 | expected_foo = "bar" if context else "default" 64 | assert response.json() == {"data": {"foo": expected_foo}} 65 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |
2 | tartiflette-asgi logo 3 |
4 | 5 | # Introduction 6 | 7 | `tartiflette-asgi` is a wrapper that provides ASGI support for the [Tartiflette](https://tartiflette.io) Python GraphQL engine. 8 | 9 | It is ideal for serving a GraphQL API over HTTP, or adding a GraphQL API endpoint to an existing ASGI application (e.g. FastAPI, Starlette, Quart, etc). 10 | 11 | Build a GraphQL API using Tartiflette, then use `tartiflette-asgi` to achieve the following: 12 | 13 | - Serve your GraphQL API as a standalone ASGI application using an ASGI server (e.g. Uvicorn, Daphne or Hypercorn). 14 | - Mount your GraphQL API endpoint onto an existing ASGI application. 15 | - Make interactive queries using the built-in GraphiQL client. 16 | - Implement real-time querying thanks to GraphQL subscriptions over WebSocket. 17 | 18 | ## Requirements 19 | 20 | `tartiflette-asgi` is compatible with: 21 | 22 | - Python 3.6, 3.7 or 3.8. 23 | - Tartiflette 1.x. 24 | 25 | ## Installation 26 | 27 | First, install Tartiflette's external dependencies, as explained in the [Tartiflette tutorial](https://tartiflette.io/docs/tutorial/install-tartiflette). 28 | 29 | Then, you can install Tartiflette and `tartiflette-asgi` using `pip`: 30 | 31 | ```bash 32 | pip install tartiflette "tartiflette-asgi==0.*" 33 | ``` 34 | 35 | You'll also need an [ASGI web server](https://github.com/florimondmanca/awesome-asgi#servers). We'll use [Uvicorn](http://www.uvicorn.org/) throughout this documentation: 36 | 37 | ```bash 38 | pip install uvicorn 39 | ``` 40 | 41 | ## Quickstart 42 | 43 | Create an application that exposes a `TartifletteApp` instance: 44 | 45 | ```python 46 | from tartiflette import Resolver 47 | from tartiflette_asgi import TartifletteApp 48 | 49 | @Resolver("Query.hello") 50 | async def hello(parent, args, context, info): 51 | name = args["name"] 52 | return f"Hello, {name}!" 53 | 54 | sdl = "type Query { hello(name: String): String }" 55 | app = TartifletteApp(sdl=sdl, path="/graphql") 56 | ``` 57 | 58 | Save this file as `graphql.py`, then start the server: 59 | 60 | ```bash 61 | uvicorn graphql:app 62 | ``` 63 | 64 | Make an HTTP request containing a GraphQL query: 65 | 66 | ```bash 67 | curl http://localhost:8000/graphql -d '{ hello(name: "Chuck") }' -H "Content-Type: application/graphql" 68 | ``` 69 | 70 | You should get the following JSON response: 71 | 72 | ```json 73 | { "data": { "hello": "Hello, Chuck!" } } 74 | ``` 75 | -------------------------------------------------------------------------------- /tests/test_graphiql.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json 3 | import re 4 | import typing 5 | 6 | import pytest 7 | from tartiflette import Engine 8 | 9 | from tartiflette_asgi import GraphiQL, TartifletteApp 10 | 11 | from ._utils import get_client 12 | 13 | 14 | @pytest.fixture( 15 | name="graphiql", params=[False, True, GraphiQL(), GraphiQL(path="/graphql")] 16 | ) 17 | def fixture_graphiql(request: typing.Any) -> typing.Union[GraphiQL, bool]: 18 | return request.param 19 | 20 | 21 | @pytest.fixture(name="path") 22 | def fixture_path(graphiql: typing.Any) -> str: 23 | if not graphiql or graphiql is True or graphiql.path is None: 24 | return "" 25 | return graphiql.path 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_graphiql(engine: Engine, graphiql: typing.Any, path: str) -> None: 30 | app = TartifletteApp(engine=engine, graphiql=graphiql) 31 | async with get_client(app) as client: 32 | response = await client.get(path, headers={"accept": "text/html"}) 33 | 34 | assert response.status_code == 200 if graphiql else 404 35 | 36 | if response.status_code == 200: 37 | assert "" in response.text 38 | m = re.search(r"var (\w+) = `/`;", response.text) 39 | assert m is not None, response.text 40 | endpoint_variable_name = m.group(1) 41 | assert f"fetch({endpoint_variable_name}," in response.text 42 | assert "None" not in response.text 43 | else: 44 | assert response.text == "Not Found" 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_graphiql_not_found(engine: Engine, path: str) -> None: 49 | app = TartifletteApp(engine=engine) 50 | async with get_client(app) as client: 51 | response = await client.get(path + "foo") 52 | assert response.status_code == 404 53 | assert response.text == "Not Found" 54 | 55 | 56 | @pytest.fixture(name="variables") 57 | def fixture_variables() -> dict: 58 | return {"name": "Alice"} 59 | 60 | 61 | @pytest.fixture(name="query") 62 | def fixture_query() -> str: 63 | return """ 64 | query Hello($name: String) { 65 | hello(name: $name) 66 | } 67 | """ 68 | 69 | 70 | @pytest.fixture(name="headers") 71 | def fixture_headers() -> dict: 72 | return {"Authorization": "Bearer 123"} 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_defaults( 77 | engine: Engine, variables: dict, query: str, headers: dict 78 | ) -> None: 79 | graphiql = GraphiQL( 80 | default_variables=variables, default_query=query, default_headers=headers 81 | ) 82 | app = TartifletteApp(engine=engine, graphiql=graphiql) 83 | 84 | async with get_client(app) as client: 85 | response = await client.get("/", headers={"accept": "text/html"}) 86 | 87 | assert response.status_code == 200 88 | assert json.dumps(variables) in response.text 89 | assert inspect.cleandoc(query) in response.text 90 | assert json.dumps(headers) in response.text 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | tartiflette-asgi logo 3 |
4 | 5 |

6 | 7 | Build status 8 | 9 | 10 | Package version 11 | 12 | 13 | Code style 14 | 15 |

16 | 17 | `tartiflette-asgi` is a wrapper that provides ASGI support for the [Tartiflette](https://tartiflette.io) Python GraphQL engine. 18 | 19 | It is ideal for serving a GraphQL API over HTTP, or adding a GraphQL API endpoint to an existing ASGI application (e.g. FastAPI, Starlette, Quart, etc). 20 | 21 | Full documentation is available at: https://tartiflette.github.io/tartiflette-asgi 22 | 23 | ## Requirements 24 | 25 | `tartiflette-asgi` is compatible with: 26 | 27 | - Python 3.6+. 28 | - Tartiflette 1.x. 29 | 30 | ## Quickstart 31 | 32 | First, install Tartiflette's external dependencies, as explained in the [Tartiflette tutorial](https://tartiflette.io/docs/tutorial/install-tartiflette). 33 | 34 | Then, you can install Tartiflette and `tartiflette-asgi` using `pip`: 35 | 36 | ```bash 37 | pip install tartiflette "tartiflette-asgi==0.*" 38 | ``` 39 | 40 | You'll also need an [ASGI web server](https://github.com/florimondmanca/awesome-asgi#servers). We'll use [Uvicorn](http://www.uvicorn.org/) here: 41 | 42 | ```bash 43 | pip install uvicorn 44 | ``` 45 | 46 | Create an application that exposes a `TartifletteApp` instance: 47 | 48 | ```python 49 | from tartiflette import Resolver 50 | from tartiflette_asgi import TartifletteApp 51 | 52 | @Resolver("Query.hello") 53 | async def hello(parent, args, context, info): 54 | name = args["name"] 55 | return f"Hello, {name}!" 56 | 57 | sdl = "type Query { hello(name: String): String }" 58 | app = TartifletteApp(sdl=sdl, path="/graphql") 59 | ``` 60 | 61 | Save this file as `graphql.py`, then start the server: 62 | 63 | ```bash 64 | uvicorn graphql:app 65 | ``` 66 | 67 | Make an HTTP request containing a GraphQL query: 68 | 69 | ```bash 70 | curl http://localhost:8000/graphql -d '{ hello(name: "Chuck") }' -H "Content-Type: application/graphql" 71 | ``` 72 | 73 | You should get the following JSON response: 74 | 75 | ```json 76 | { "data": { "hello": "Hello, Chuck!" } } 77 | ``` 78 | 79 | To learn more about using `tartiflette-asgi`, head to the documentation: https://tartiflette.github.io/tartiflette-asgi 80 | 81 | ## Contributing 82 | 83 | Want to contribute? Awesome! Be sure to read our [Contributing guidelines](https://github.com/tartiflette/tartiflette-asgi/tree/master/CONTRIBUTING.md). 84 | 85 | ## Changelog 86 | 87 | Changes to this project are recorded in the [changelog](https://github.com/tartiflette/tartiflette-asgi/tree/master/CHANGELOG.md). 88 | 89 | ## License 90 | 91 | MIT 92 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/_app.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from starlette.routing import BaseRoute, Route, Router, WebSocketRoute 4 | from starlette.types import Receive, Scope, Send 5 | from tartiflette import Engine 6 | 7 | from ._datastructures import GraphiQL, GraphQLConfig, Subscriptions 8 | from ._endpoints import GraphiQLEndpoint, GraphQLEndpoint, SubscriptionEndpoint 9 | from ._middleware import GraphQLMiddleware 10 | 11 | 12 | class TartifletteApp: 13 | def __init__( 14 | self, 15 | *, 16 | engine: Engine = None, 17 | sdl: str = None, 18 | graphiql: typing.Union[None, bool, GraphiQL] = True, 19 | path: str = "/", 20 | subscriptions: typing.Union[bool, Subscriptions] = None, 21 | context: dict = None, 22 | schema_name: str = "default", 23 | ) -> None: 24 | if engine is None: 25 | assert sdl, "`sdl` expected if `engine` not given" 26 | engine = Engine(sdl=sdl, schema_name=schema_name) 27 | 28 | assert engine, "`engine` expected if `sdl` not given" 29 | 30 | self.engine = engine 31 | 32 | if context is None: 33 | context = {} 34 | 35 | if graphiql is True: 36 | graphiql = GraphiQL() 37 | elif not graphiql: 38 | graphiql = None 39 | 40 | assert graphiql is None or isinstance(graphiql, GraphiQL) 41 | 42 | if subscriptions is True: 43 | subscriptions = Subscriptions(path="/subscriptions") 44 | elif not subscriptions: 45 | subscriptions = None 46 | 47 | assert subscriptions is None or isinstance(subscriptions, Subscriptions) 48 | 49 | routes: typing.List[BaseRoute] = [] 50 | 51 | if graphiql and graphiql.path is not None: 52 | routes.append(Route(graphiql.path, GraphiQLEndpoint)) 53 | 54 | routes.append(Route(path, GraphQLEndpoint)) 55 | 56 | if subscriptions is not None: 57 | routes.append(WebSocketRoute(subscriptions.path, SubscriptionEndpoint)) 58 | 59 | self.router = Router(routes=routes, on_startup=[self.startup]) 60 | 61 | config = GraphQLConfig( 62 | engine=self.engine, 63 | context=context, 64 | graphiql=graphiql, 65 | path=path, 66 | subscriptions=subscriptions, 67 | ) 68 | 69 | self.app = GraphQLMiddleware(self.router, config=config) 70 | 71 | self._started_up = False 72 | 73 | async def startup(self) -> None: 74 | await self.engine.cook() 75 | self._started_up = True 76 | 77 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 78 | if scope["type"] == "lifespan": 79 | await self.router.lifespan(scope, receive, send) 80 | else: 81 | if not self._started_up: 82 | raise RuntimeError( 83 | "GraphQL engine is not ready.\n\n" 84 | "HINT: you must register the startup event handler on the " 85 | "parent ASGI application.\n" 86 | "Starlette example:\n\n" 87 | " app.mount('/graphql', graphql)\n" 88 | " app.add_event_handler('startup', graphql.startup)" 89 | ) 90 | await self.app(scope, receive, send) 91 | -------------------------------------------------------------------------------- /tests/test_mount.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.applications import Starlette 3 | from starlette.requests import Request 4 | from starlette.responses import PlainTextResponse 5 | from starlette.routing import Mount, Route 6 | from tartiflette import Engine 7 | 8 | from tartiflette_asgi import TartifletteApp 9 | 10 | from ._utils import get_client, omit_none 11 | 12 | app = Starlette() 13 | 14 | 15 | @pytest.mark.asyncio 16 | @pytest.mark.parametrize("mount_path", ("/", "/graphql")) 17 | @pytest.mark.parametrize("path", [None, "/", "/graphql", "/graphql/"]) 18 | async def test_starlette_mount(engine: Engine, mount_path: str, path: str) -> None: 19 | kwargs = omit_none({"engine": engine, "path": path}) 20 | 21 | graphql = TartifletteApp(**kwargs) 22 | routes = [Mount(mount_path, graphql)] 23 | app = Starlette(routes=routes, on_startup=[graphql.startup]) 24 | 25 | query = "{ hello }" 26 | full_path = mount_path.rstrip("/") + ("/" if path is None else path) 27 | assert "//" not in full_path 28 | 29 | url = f"{full_path}?query={query}" 30 | async with get_client(app) as client: 31 | response = await client.get(url) 32 | graphiql_response = await client.get(url, headers={"accept": "text/html"}) 33 | 34 | assert response.status_code == 200 35 | assert response.json() == {"data": {"hello": "Hello stranger"}} 36 | 37 | assert graphiql_response.status_code == 200 38 | assert full_path in graphiql_response.text 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_must_register_startup_handler(engine: Engine) -> None: 43 | graphql = TartifletteApp(engine=engine) 44 | app = Starlette(routes=[Mount("/graphql", graphql)], on_startup=[]) 45 | 46 | async with get_client(app) as client: 47 | with pytest.raises(RuntimeError) as ctx: 48 | await client.get("/graphql/") 49 | 50 | error = str(ctx.value).lower() 51 | assert "hint" in error 52 | assert "starlette example" in error 53 | assert ".add_event_handler" in error 54 | assert "'startup'" in error 55 | assert ".startup" in error 56 | 57 | 58 | @pytest.mark.asyncio 59 | @pytest.mark.parametrize("mount_path, url", [("", "/"), ("/graphql", "/graphql/")]) 60 | async def test_graphiql_endpoint_paths_when_mounted( 61 | engine: Engine, mount_path: str, url: str 62 | ) -> None: 63 | graphql = TartifletteApp(engine=engine, graphiql=True, subscriptions=True) 64 | app = Starlette(routes=[Mount(mount_path, graphql)], on_startup=[graphql.startup]) 65 | 66 | async with get_client(app) as client: 67 | response = await client.get(url, headers={"accept": "text/html"}) 68 | 69 | assert response.status_code == 200 70 | 71 | graphql_endpoint = mount_path + "/" 72 | assert f"var graphQLEndpoint = `{graphql_endpoint}`;" in response.text 73 | 74 | subscriptions_endpoint = mount_path + "/subscriptions" 75 | assert f"var subscriptionsEndpoint = `{subscriptions_endpoint}`;" in response.text 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_tartiflette_app_as_sub_starlette_app(engine: Engine) -> None: 80 | async def home(_request: Request) -> PlainTextResponse: 81 | return PlainTextResponse("Hello, world!") 82 | 83 | graphql = TartifletteApp(engine=engine) 84 | routes = [ 85 | Route("/", endpoint=home), 86 | Mount("/graphql", app=graphql, name="tartiflette-asgi"), 87 | ] 88 | app = Starlette(routes=routes, on_startup=[graphql.startup]) 89 | 90 | async with get_client(app) as client: 91 | response = await client.get("/") 92 | assert response.status_code == 200 93 | assert response.text == "Hello, world!" 94 | response = await client.get("/graphql/?query={ hello }") 95 | assert response.status_code == 200 96 | assert response.json() == {"data": {"hello": "Hello stranger"}} 97 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | !!! note 4 | Unless specified otherwise, components documented here can be imported from `tartiflette_asgi` directly, e.g. `from tartiflette_asgi import TartifletteApp`. 5 | 6 | ## `TartifletteApp` 7 | 8 | ### Parameters 9 | 10 | **Note**: all parameters are keyword-only. 11 | 12 | - `engine` (`Engine`): a Tartiflette [engine](https://tartiflette.io/docs/api/engine). Required if `sdl` is not given. 13 | - `sdl` (`str`): a GraphQL schema defined using the [GraphQL Schema Definition Language](https://graphql.org/learn/schema/). Required if `engine` is not given. 14 | - `path` (`str`, optional): the path which clients should make GraphQL queries to. Defaults to `"/"`. 15 | - `graphiql` (`GraphiQL` or `bool`, optional): configuration for the GraphiQL client. Defaults to `True`, which is equivalent to `GraphiQL()`. Use `False` to not register the GraphiQL client. 16 | - `subscriptions` (`Subscriptions` or `bool`, optional): subscriptions configuration. Defaults to `True`, which is equivalent to `Subscriptions(path="/subscriptions")`. Leave empty or pass `None` to not register the subscription WebSocket endpoint. 17 | - `context` (`dict`, optional): a copy of this dictionary is passed to resolvers when executing a query. Defaults to `{}`. Note: the Starlette `Request` object is always present as `req`. 18 | - `schema_name` (`str`, optional): name of the GraphQL schema from the [Schema Registry](https://tartiflette.io/docs/api/schema-registry/) which should be used — mostly for advanced usage. Defaults to `"default"`. 19 | 20 | ### Methods 21 | 22 | - `__call__(scope, receive, send)`: ASGI3 implementation. 23 | 24 | ### Error responses 25 | 26 | | Status code | Description | 27 | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | 28 | | 400 Bad Request | The GraphQL query could not be found in the request data. | 29 | | 404 Not Found | The request does not match the GraphQL or GraphiQL endpoint paths. | 30 | | 405 Method Not Allowed | The HTTP method is not one of `GET`, `HEAD` or `POST`. | 31 | | 415 Unsupported Media Type | The POST request made to the GraphQL endpoint uses a `Content-Type` different from `application/json` and `application/graphql`. | 32 | 33 | ## `GraphiQL` 34 | 35 | Configuration helper for the GraphiQL client. 36 | 37 | ### Parameters 38 | 39 | **Note**: all parameters are keyword-only. 40 | 41 | - `path` (`str`, optional): the path of the GraphiQL endpoint, **relative to the root path which `TartifletteApp` is served at**. If not given, defaults to the `path` given to `TartifletteApp`. 42 | - `default_headers` (`dict`, optional): extra HTTP headers to send when calling the GraphQL endpoint. 43 | - `default_query` (`str`, optional): the default query to display when accessing the GraphiQL interface. 44 | - `default_variables` (`dict`, optional): default [variables][graphql-variables] to display when accessing the GraphiQL interface. 45 | - `template` (`str`, optional): an HTML template to use instead of the default one. In the template, `default_headers`, `default_query` and `default_variables`, as well as the GraphQL `endpoint`, are available as strings (JSON-encoded if needed) using template string substitutions, e.g.: 46 | 47 | ```js 48 | const endpoint = `${endpoint}`; // This is where the API call should be made. 49 | const defaultHeaders = JSON.parse(`${default_headers}`); 50 | ``` 51 | 52 | [graphql-variables]: https://graphql.org/learn/queries/#variables 53 | 54 | ## `Subscriptions` 55 | 56 | Configuration helper for WebSocket subscriptions. 57 | 58 | ### Parameters 59 | 60 | **Note**: all parameters are keyword-only. 61 | 62 | - `path` (`str`): the path of the subscriptions WebSocket endpoint, **relative to the root path which `TartifletteApp` is served at**. If not given, defaults to `/subscriptions`. 63 | -------------------------------------------------------------------------------- /tests/test_subscription.py: -------------------------------------------------------------------------------- 1 | import time 2 | import typing 3 | 4 | import pytest 5 | from starlette.testclient import TestClient 6 | from starlette.websockets import WebSocket, WebSocketDisconnect 7 | from tartiflette import Engine 8 | 9 | from tartiflette_asgi import Subscriptions, TartifletteApp 10 | 11 | from ._utils import Dog, PubSub 12 | 13 | MISSING = object() 14 | 15 | 16 | @pytest.mark.parametrize("subscriptions", [MISSING, None]) 17 | def test_if_subscriptions_disabled_then_cannot_connect( 18 | engine: Engine, subscriptions: typing.Any 19 | ) -> None: 20 | kwargs = {} 21 | if subscriptions is not MISSING: 22 | kwargs["subscriptions"] = subscriptions 23 | 24 | app = TartifletteApp(engine=engine, **kwargs) 25 | 26 | with TestClient(app) as client: # type: TestClient # type: ignore 27 | with pytest.raises(WebSocketDisconnect): 28 | with client.websocket_connect("/subscriptions"): 29 | pass 30 | 31 | 32 | @pytest.fixture(name="pubsub", scope="session") 33 | def fixture_pubsub() -> PubSub: 34 | from ._utils import pubsub 35 | 36 | return pubsub 37 | 38 | 39 | @pytest.fixture(name="subscriptions", params=[True, Subscriptions(path="/subs")]) 40 | def fixture_subscriptions(request: typing.Any) -> typing.Union[Subscriptions, bool]: 41 | return request.param 42 | 43 | 44 | @pytest.fixture(name="path") 45 | def fixture_path(subscriptions: typing.Any) -> str: 46 | if subscriptions is True: 47 | return "/subscriptions" 48 | return subscriptions.path 49 | 50 | 51 | def _init(ws: WebSocket) -> None: 52 | ws.send_json({"type": "connection_init"}) # type: ignore 53 | assert ws.receive_json() == {"type": "connection_ack"} 54 | 55 | 56 | def _terminate(ws: WebSocket) -> None: 57 | ws.send_json({"type": "connection_terminate"}) # type: ignore 58 | 59 | 60 | def test_protocol_connect_disconnect( 61 | engine: Engine, subscriptions: typing.Any, pubsub: PubSub, path: str 62 | ) -> None: 63 | app = TartifletteApp( 64 | engine=engine, subscriptions=subscriptions, context={"pubsub": pubsub} 65 | ) 66 | 67 | with TestClient(app) as client: # type: typing.Any 68 | with client.websocket_connect(path) as ws: 69 | _init(ws) 70 | _terminate(ws) 71 | with pytest.raises(WebSocketDisconnect) as ctx: 72 | ws.receive_json() 73 | 74 | exc: WebSocketDisconnect = ctx.value 75 | assert exc.code == 1011 76 | 77 | 78 | def test_subscribe( 79 | engine: Engine, subscriptions: typing.Any, pubsub: PubSub, path: str 80 | ) -> None: 81 | def _emit(dog: Dog = None) -> None: 82 | time.sleep(0.1) 83 | pubsub.emit("dog_added", dog) 84 | 85 | gaspar = Dog(id=1, name="Gaspar", nickname="Rapsag") 86 | woofy = Dog(id=2, name="Merrygold", nickname="Woofy") 87 | 88 | app = TartifletteApp( 89 | engine=engine, subscriptions=subscriptions, context={"pubsub": pubsub} 90 | ) 91 | 92 | with TestClient(app) as client: # type: typing.Any 93 | with client.websocket_connect(path) as ws: 94 | _init(ws) 95 | 96 | ws.send_json( 97 | { 98 | "type": "start", 99 | "id": "myquery", 100 | "payload": { 101 | "query": """ 102 | subscription DogAdded { 103 | dogAdded { 104 | id 105 | name 106 | nickname 107 | } 108 | } 109 | """ 110 | }, 111 | } 112 | ) 113 | 114 | _emit(gaspar) 115 | assert ws.receive_json() == { 116 | "id": "myquery", 117 | "type": "data", 118 | "payload": { 119 | "data": { 120 | "dogAdded": {"id": 1, "name": "Gaspar", "nickname": "Rapsag"} 121 | } 122 | }, 123 | } 124 | 125 | _emit(woofy) 126 | assert ws.receive_json() == { 127 | "id": "myquery", 128 | "type": "data", 129 | "payload": { 130 | "data": { 131 | "dogAdded": {"id": 2, "name": "Merrygold", "nickname": "Woofy"} 132 | } 133 | }, 134 | } 135 | 136 | _emit(None) 137 | assert ws.receive_json() == {"id": "myquery", "type": "complete"} 138 | 139 | _terminate(ws) 140 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## 0.12.0 - 2022-05-13 9 | 10 | ### Added 11 | 12 | - Add support for variables in query parameters. (Pull #176) 13 | 14 | ## 0.11.0 - 2021-09-03 15 | 16 | ### Added 17 | 18 | - Add official compatibility with Tartiflette 1.4.x. (Pull #153) 19 | 20 | ### Changed 21 | 22 | - Update dependency on Starlette to `0.16.*`. (Pull #154) 23 | - Update httpx requirement to `0.19.*` (Pull #159) 24 | 25 | ## 0.10.0 - 2021-01-19 26 | 27 | ### Added 28 | 29 | - Add official compatibility with Tartiflette 1.3.x. (Pull #132) 30 | 31 | ## 0.9.0 - 2020-06-10 32 | 33 | ### Removed 34 | 35 | - Drop deprecated `mount` helpers module. Prefer using the mounting mechanism of your ASGI framework. (Pull #119) 36 | 37 | ### Changed 38 | 39 | - Update dependency on Starlette to `0.13.*`. (Pull #106) 40 | - Convert internal modules to private naming. All public API should be accessed from the top-level `tartiflette_asgi` package. (Pull #117) 41 | 42 | ### Added 43 | 44 | - Add compatibility with Starlette `0.13.*`. (Pull #106) 45 | 46 | ## 0.8.0 - 2020-06-04 47 | 48 | ### Added 49 | 50 | - Add support for Tartiflette 1.2.x. (Pull #115) 51 | 52 | ### Fixed 53 | 54 | - Fix GraphiQL subscriptions endpoint when using ASGI sub-mounting. (Pull #98) 55 | - Fix protocol mismatch error when serving GraphiQL over HTTPS. (Pull #114) 56 | 57 | ## 0.7.1 - 2019-10-28 58 | 59 | ### Fixed 60 | 61 | - Requests containing malformed JSON now return a 400 Bad Request error response instead of 500 Internal Server Error. (Pull #81) 62 | 63 | ## 0.7.0 - 2019-10-27 64 | 65 | ### Changed 66 | 67 | - Renamed project to `tartiflette-asgi`. 68 | 69 | ## 0.6.0 - 2019-10-18 70 | 71 | ### Added 72 | 73 | - Add support for Tartiflette 1.x. (Pull #58) 74 | - Officialize support for Python 3.8. (Pull #80) 75 | 76 | ### Removed 77 | 78 | - Drop support for Tartiflette 0.x. (Pull #58) 79 | 80 | ## 0.5.2 - 2019-10-09 81 | 82 | ### Added 83 | 84 | - Add support for Python 3.8. (Pull #55) 85 | 86 | ### Fixed 87 | 88 | - Type annotations are now correctly detected by `mypy`. (Pull #66) 89 | - Fix a bug that prevented the GraphiQL web interface from making queries when the application was mounted on a parent ASGI app. (Pull #51) 90 | 91 | ## 0.5.1 - 2019-07-16 92 | 93 | ### Fixed 94 | 95 | - Fixed a bug that prevented accessing the GraphiQL interface when subscriptions were not enabled. 96 | 97 | ## 0.5.0 - 2019-07-12 98 | 99 | ### Added 100 | 101 | - WebSocket subscriptions, configurable with the new `subscriptions` option on `TartifletteApp`. 102 | - Pass extra context to resolvers using the new `context` option on `TartifletteApp`. 103 | 104 | ## 0.4.0 - 2019-07-04 105 | 106 | ### Added 107 | 108 | - Support for Tartiflette 0.12.x. 109 | - Add a `mount` module with submounting helpers. 110 | - Add `mount.starlette()`. 111 | 112 | ### Changed 113 | 114 | - Due to the new [engine cooking API](https://tartiflette.io/docs/api/engine#cook-your-tartiflette) in Tartiflette 0.12, `TartifletteApp` now includes a startup event handler responsible for building the GraphQL engine. If submounting, it **must** be registered on the parent ASGI app. Helpers in the `mount` module take care of this for you. 115 | 116 | ### Removed 117 | 118 | - Drop support for Tartiflette 0.11.x and below. 119 | 120 | ## 0.3.0 - 2019-07-03 121 | 122 | ### Added 123 | 124 | - GraphiQL configuration via the `GraphiQL` helper. Options: `path`, `default_query`, `default_headers`, `default_variables`, `template`. 125 | 126 | ### Changed 127 | 128 | - Internal refactoring that leverages more of Starlette's capabilities. 129 | - Documentation improvements. 130 | 131 | ## 0.2.0 - 2019-06-10 132 | 133 | ### Added 134 | 135 | - Support for `starlette>=0.12` (previously `>=0.12.0b3`). 136 | - Tartiflette is now installed too when installing `tartiflette-asgi`. 137 | 138 | ### Changed 139 | 140 | - The default `path` is now `""` (previously `"/"`). 141 | - The request is now accessible in the GraphQL context via `context["req"]` (previously `context["request"]`). 142 | - If no error occurred, the `errors` field is not present in the response anymore (previously was `None`). 143 | 144 | ### Fixed 145 | 146 | - More robust URL matching on `TartifletteApp`. 147 | 148 | ## 0.1.1 - 2019-04-28 149 | 150 | ### Fixed 151 | 152 | - Add missing `graphiql.html` package asset. 153 | 154 | ## 0.1.0 - 2019-04-26 155 | 156 | ### Added 157 | 158 | Features: 159 | 160 | - `TartifletteApp` ASGI application. 161 | - Built-in GraphiQL client. 162 | 163 | Project-related additions: 164 | 165 | - Package setup. 166 | - Changelog. 167 | - Contributing guide. 168 | - README and documentation. 169 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/_endpoints.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing 3 | 4 | from starlette.background import BackgroundTasks 5 | from starlette.datastructures import QueryParams 6 | from starlette.endpoints import HTTPEndpoint, WebSocketEndpoint 7 | from starlette.requests import Request 8 | from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response 9 | from starlette.types import ASGIApp, Receive, Scope, Send 10 | from starlette.websockets import WebSocket 11 | from tartiflette import Engine 12 | 13 | from ._errors import format_errors 14 | from ._middleware import get_graphql_config 15 | from ._subscriptions import GraphQLWSProtocol 16 | 17 | 18 | class GraphiQLEndpoint(HTTPEndpoint): 19 | async def get(self, request: Request) -> Response: 20 | config = get_graphql_config(request) 21 | graphql_endpoint = request["root_path"] + config.path 22 | subscriptions_endpoint = None 23 | if config.subscriptions: 24 | subscriptions_endpoint = request["root_path"] + config.subscriptions.path 25 | graphiql = config.graphiql 26 | assert graphiql is not None 27 | html = graphiql.render_template( 28 | graphql_endpoint=graphql_endpoint, 29 | subscriptions_endpoint=subscriptions_endpoint, 30 | ) 31 | return HTMLResponse(html) 32 | 33 | 34 | class GraphQLEndpoint(HTTPEndpoint): 35 | async def get(self, request: Request) -> Response: 36 | variables = None 37 | if "variables" in request.query_params: 38 | try: 39 | variables = json.loads(request.query_params["variables"]) 40 | except json.JSONDecodeError: 41 | return JSONResponse( 42 | {"error": "Unable to decode variables: Invalid JSON."}, 400 43 | ) 44 | return await self._get_response( 45 | request, data=request.query_params, variables=variables 46 | ) 47 | 48 | async def post(self, request: Request) -> Response: 49 | content_type = request.headers.get("Content-Type", "") 50 | 51 | variables = None 52 | if "variables" in request.query_params: 53 | try: 54 | variables = json.loads(request.query_params["variables"]) 55 | except json.JSONDecodeError: 56 | return JSONResponse( 57 | {"error": "Unable to decode variables: Invalid JSON."}, 400 58 | ) 59 | 60 | if "application/json" in content_type: 61 | try: 62 | data = await request.json() 63 | except json.JSONDecodeError: 64 | return JSONResponse({"error": "Invalid JSON."}, 400) 65 | variables = data.get("variables", variables) 66 | elif "application/graphql" in content_type: 67 | body = await request.body() 68 | data = {"query": body.decode()} 69 | elif "query" in request.query_params: 70 | data = request.query_params 71 | else: 72 | return PlainTextResponse("Unsupported Media Type", 415) 73 | 74 | return await self._get_response(request, data=data, variables=variables) 75 | 76 | async def _get_response( 77 | self, request: Request, data: QueryParams, variables: typing.Optional[dict] 78 | ) -> Response: 79 | try: 80 | query = data["query"] 81 | except KeyError: 82 | return PlainTextResponse("No GraphQL query found in the request", 400) 83 | 84 | config = get_graphql_config(request) 85 | background = BackgroundTasks() 86 | context = {"req": request, "background": background, **config.context} 87 | 88 | engine: Engine = config.engine 89 | result: dict = await engine.execute( 90 | query, 91 | context=context, 92 | variables=variables, 93 | operation_name=data.get("operationName"), 94 | ) 95 | 96 | content = {"data": result["data"]} 97 | has_errors = "errors" in result 98 | if has_errors: 99 | content["errors"] = format_errors(result["errors"]) 100 | status = 400 if has_errors else 200 101 | 102 | return JSONResponse(content=content, status_code=status, background=background) 103 | 104 | async def dispatch(self) -> None: 105 | request = Request(self.scope, self.receive) 106 | graphiql = get_graphql_config(request).graphiql 107 | if "text/html" in request.headers.get("Accept", ""): 108 | app: ASGIApp 109 | if graphiql and graphiql.path is None: 110 | app = GraphiQLEndpoint 111 | else: 112 | app = PlainTextResponse("Not Found", 404) 113 | await app(self.scope, self.receive, self.send) 114 | else: 115 | await super().dispatch() 116 | 117 | 118 | class SubscriptionEndpoint(WebSocketEndpoint): 119 | encoding = "json" # type: ignore 120 | 121 | def __init__(self, scope: Scope, receive: Receive, send: Send) -> None: 122 | super().__init__(scope, receive, send) 123 | self.protocol: typing.Optional[GraphQLWSProtocol] = None 124 | 125 | async def on_connect(self, websocket: WebSocket) -> None: 126 | await websocket.accept(subprotocol=GraphQLWSProtocol.name) 127 | config = get_graphql_config(websocket) 128 | self.protocol = GraphQLWSProtocol( 129 | websocket=websocket, engine=config.engine, context=dict(config.context) 130 | ) 131 | 132 | async def on_receive(self, websocket: WebSocket, data: typing.Any) -> None: 133 | assert self.protocol is not None 134 | await self.protocol.on_receive(message=data) 135 | 136 | async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None: 137 | assert self.protocol is not None 138 | await self.protocol.on_disconnect(close_code) 139 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/_subscriptions/protocol.py: -------------------------------------------------------------------------------- 1 | """Sans-IO base implementation of the GraphQL over WebSocket protocol. 2 | 3 | See: https://github.com/apollographql/subscriptions-transport-ws 4 | """ 5 | import json 6 | import sys 7 | import typing 8 | 9 | from .constants import GQL 10 | 11 | if sys.version_info >= (3, 8): # pragma: no cover 12 | from typing import TypedDict 13 | else: # pragma: no cover 14 | from typing_extensions import TypedDict 15 | 16 | 17 | class Payload(TypedDict): 18 | context: dict 19 | query: typing.Union[str, bytes] 20 | variables: typing.Optional[typing.Dict[str, typing.Any]] 21 | operationName: typing.Optional[str] 22 | 23 | 24 | class Subscription: 25 | def __init__( 26 | self, 27 | agen: typing.AsyncGenerator[typing.Dict[str, typing.Any], None], 28 | ) -> None: 29 | self._agen = agen 30 | 31 | async def __aiter__(self) -> typing.AsyncIterator[typing.Dict[str, typing.Any]]: 32 | async for item in self._agen: 33 | yield item 34 | 35 | async def aclose(self) -> None: 36 | await self._agen.aclose() 37 | 38 | 39 | class GraphQLWSProtocol: 40 | name = "graphql-ws" 41 | 42 | def __init__(self) -> None: 43 | self._subscriptions: typing.Dict[str, Subscription] = {} 44 | 45 | # Methods whose implementation is left to the implementer. 46 | 47 | def schedule(self, coro: typing.Coroutine) -> None: 48 | raise NotImplementedError 49 | 50 | async def send_json(self, message: dict) -> None: 51 | raise NotImplementedError 52 | 53 | async def close(self, close_code: int) -> None: 54 | raise NotImplementedError 55 | 56 | def get_subscription(self, opid: str, payload: Payload) -> Subscription: 57 | raise NotImplementedError 58 | 59 | # Helpers. 60 | 61 | async def _send_message( 62 | self, 63 | opid: typing.Optional[str] = None, 64 | optype: typing.Optional[str] = None, 65 | payload: typing.Optional[typing.Any] = None, 66 | ) -> None: 67 | items = (("id", opid), ("type", optype), ("payload", payload)) 68 | message = {k: v for k, v in items if v is not None} 69 | await self.send_json(message) 70 | 71 | async def _send_error( 72 | self, 73 | message: str, 74 | opid: typing.Optional[str] = None, 75 | error_type: typing.Optional[str] = None, 76 | ) -> None: 77 | if error_type not in {GQL.ERROR, GQL.CONNECTION_ERROR}: 78 | error_type = GQL.ERROR 79 | await self._send_message(opid, error_type, {"message": message}) 80 | 81 | async def _subscribe(self, opid: str, payload: Payload) -> None: 82 | subscription = self.get_subscription(opid, payload) 83 | self._subscriptions[opid] = subscription 84 | 85 | try: 86 | async for item in subscription: 87 | if opid not in self._subscriptions: 88 | break 89 | await self._send_message(opid, optype="data", payload=item) 90 | except Exception as exc: 91 | await self._send_error("Internal error", opid=opid) 92 | raise exc 93 | 94 | await self._send_message(opid, "complete") 95 | await self._unsubscribe(opid) 96 | 97 | async def _unsubscribe(self, opid: str) -> None: 98 | subscription = self._subscriptions.pop(opid, None) 99 | if subscription is None: 100 | return 101 | await subscription.aclose() 102 | 103 | # Client message handlers. 104 | 105 | async def _on_connection_init(self, opid: str, payload: Payload) -> None: 106 | try: 107 | await self._send_message(optype=GQL.CONNECTION_ACK) 108 | except Exception as exc: 109 | await self._send_error(str(exc), opid=opid, error_type=GQL.CONNECTION_ERROR) 110 | await self.close(1011) 111 | 112 | async def _on_start(self, opid: str, payload: Payload) -> None: 113 | if opid in self._subscriptions: 114 | await self._unsubscribe(opid) 115 | await self._subscribe(opid, payload) 116 | 117 | async def _on_stop(self, opid: str, payload: Payload) -> None: 118 | await self._unsubscribe(opid) 119 | 120 | async def _on_connection_terminate(self, opid: str, payload: Payload) -> None: 121 | await self.close(1011) 122 | 123 | # Main task. 124 | 125 | async def _main(self, message: typing.Any) -> None: 126 | if not isinstance(message, dict): 127 | try: 128 | message = json.loads(message) 129 | if not isinstance(message, dict): 130 | raise TypeError("payload must be a JSON object") 131 | except TypeError as exc: 132 | await self._send_error(str(exc)) 133 | 134 | optype: str = message.get("type") 135 | opid: str = message.get("id") 136 | payload: Payload = message.get("payload", {}) 137 | 138 | handler: typing.Callable[[str, Payload], typing.Awaitable[None]] 139 | 140 | if optype == "connection_init": 141 | handler = self._on_connection_init 142 | elif optype == "start": 143 | handler = self._on_start 144 | elif optype == "stop": 145 | handler = self._on_stop 146 | elif optype == "connection_terminate": 147 | handler = self._on_connection_terminate 148 | else: 149 | await self._send_error(f"Unsupported message type: {optype}", opid=opid) 150 | return 151 | 152 | await handler(opid=opid, payload=payload) 153 | 154 | # Public API. 155 | 156 | async def on_receive(self, message: typing.Any) -> None: 157 | # Subscription execution is a long-lived `async for` operation, 158 | # so we must schedule it in a separate task on the event loop. 159 | self.schedule(self._main(message)) 160 | 161 | async def on_disconnect(self, close_code: int) -> None: 162 | # NOTE: load keys in list to prevent "size changed during iteration". 163 | for opid in list(self._subscriptions): 164 | await self._unsubscribe(opid) 165 | -------------------------------------------------------------------------------- /src/tartiflette_asgi/graphiql.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 22 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
Loading...
42 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /tests/test_graphql_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tartiflette import Engine 3 | 4 | from tartiflette_asgi import TartifletteApp 5 | 6 | from ._utils import get_client 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_get_querystring(engine: Engine) -> None: 11 | app = TartifletteApp(engine=engine) 12 | async with get_client(app) as client: 13 | response = await client.get("/?query={ hello }") 14 | assert response.status_code == 200 15 | assert response.json() == {"data": {"hello": "Hello stranger"}} 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_get_querystring_variables(engine: Engine) -> None: 20 | app = TartifletteApp(engine=engine) 21 | async with get_client(app) as client: 22 | response = await client.get( 23 | ( 24 | "/?query=query($name: String) { hello(name: $name) }" 25 | '&variables={ "name": "world" }' 26 | ) 27 | ) 28 | assert response.status_code == 200 29 | assert response.json() == {"data": {"hello": "Hello world"}} 30 | 31 | 32 | @pytest.mark.asyncio 33 | @pytest.mark.parametrize("path", ("/", "/?foo=bar", "/?q={ hello }")) 34 | async def test_get_no_query(engine: Engine, path: str) -> None: 35 | app = TartifletteApp(engine=engine) 36 | async with get_client(app) as client: 37 | response = await client.get(path) 38 | assert response.status_code == 400 39 | assert response.text == "No GraphQL query found in the request" 40 | 41 | 42 | @pytest.mark.asyncio 43 | async def test_get_invalid_json(engine: Engine) -> None: 44 | app = TartifletteApp(engine=engine) 45 | async with get_client(app) as client: 46 | response = await client.get("/?query={ hello }&variables={test") 47 | assert response.status_code == 400 48 | assert response.json() == {"error": "Unable to decode variables: Invalid JSON."} 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_post_querystring(engine: Engine) -> None: 53 | app = TartifletteApp(engine=engine) 54 | async with get_client(app) as client: 55 | response = await client.post("/?query={ hello }") 56 | assert response.status_code == 200 57 | assert response.json() == {"data": {"hello": "Hello stranger"}} 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_post_querystring_variables(engine: Engine) -> None: 62 | app = TartifletteApp(engine=engine) 63 | async with get_client(app) as client: 64 | response = await client.post( 65 | ( 66 | "/?query=query($name: String) { hello(name: $name) }" 67 | '&variables={ "name": "world" }' 68 | ) 69 | ) 70 | assert response.status_code == 200 71 | assert response.json() == {"data": {"hello": "Hello world"}} 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_post_querystring_invalid_json(engine: Engine) -> None: 76 | app = TartifletteApp(engine=engine) 77 | async with get_client(app) as client: 78 | response = await client.post( 79 | "/?query=query($name: String) { hello(name: $name) }&variables={test" 80 | ) 81 | assert response.status_code == 400 82 | assert response.json() == {"error": "Unable to decode variables: Invalid JSON."} 83 | 84 | 85 | @pytest.mark.asyncio 86 | async def test_post_json(engine: Engine) -> None: 87 | app = TartifletteApp(engine=engine) 88 | async with get_client(app) as client: 89 | response = await client.post("/", json={"query": "{ hello }"}) 90 | assert response.status_code == 200 91 | assert response.json() == {"data": {"hello": "Hello stranger"}} 92 | 93 | 94 | @pytest.mark.asyncio 95 | async def test_post_json_variables(engine: Engine) -> None: 96 | app = TartifletteApp(engine=engine) 97 | async with get_client(app) as client: 98 | response = await client.post( 99 | "/", 100 | json={ 101 | "query": "query($name: String) { hello(name: $name) }", 102 | "variables": {"name": "world"}, 103 | }, 104 | ) 105 | assert response.status_code == 200 106 | assert response.json() == {"data": {"hello": "Hello world"}} 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_post_invalid_json(engine: Engine) -> None: 111 | app = TartifletteApp(engine=engine) 112 | async with get_client(app) as client: 113 | response = await client.post( 114 | "/", content="{test", headers={"content-type": "application/json"} 115 | ) 116 | assert response.status_code == 400 117 | assert response.json() == {"error": "Invalid JSON."} 118 | 119 | 120 | @pytest.mark.asyncio 121 | async def test_post_graphql(engine: Engine) -> None: 122 | app = TartifletteApp(engine=engine) 123 | async with get_client(app) as client: 124 | response = await client.post( 125 | "/", content="{ hello }", headers={"content-type": "application/graphql"} 126 | ) 127 | assert response.status_code == 200 128 | assert response.json() == {"data": {"hello": "Hello stranger"}} 129 | 130 | 131 | @pytest.mark.asyncio 132 | async def test_post_graphql_variables(engine: Engine) -> None: 133 | app = TartifletteApp(engine=engine) 134 | async with get_client(app) as client: 135 | response = await client.post( 136 | '/?variables={ "name": "world" }', 137 | data="query($name: String) { hello(name: $name) }", 138 | headers={"content-type": "application/graphql"}, 139 | ) 140 | assert response.status_code == 200 141 | assert response.json() == {"data": {"hello": "Hello world"}} 142 | 143 | 144 | @pytest.mark.asyncio 145 | async def test_post_invalid_media_type(engine: Engine) -> None: 146 | app = TartifletteApp(engine=engine) 147 | async with get_client(app) as client: 148 | response = await client.post( 149 | "/", content="{ hello }", headers={"content-type": "dummy"} 150 | ) 151 | assert response.status_code == 415 152 | assert response.text == "Unsupported Media Type" 153 | 154 | 155 | @pytest.mark.asyncio 156 | async def test_put(engine: Engine) -> None: 157 | app = TartifletteApp(engine=engine) 158 | async with get_client(app) as client: 159 | response = await client.put("/", json={"query": "{ hello }"}) 160 | assert response.status_code == 405 161 | assert response.text == "Method Not Allowed" 162 | 163 | 164 | @pytest.mark.asyncio 165 | async def test_error_handling(engine: Engine) -> None: 166 | app = TartifletteApp(engine=engine) 167 | async with get_client(app) as client: 168 | response = await client.post("/", json={"query": "{ dummy }"}) 169 | assert response.status_code == 400 170 | json = response.json() 171 | assert json["data"] is None 172 | assert len(json["errors"]) == 1 173 | error = json["errors"][0] 174 | assert error["locations"] == [{"column": 3, "line": 1}] 175 | assert "dummy" in error["message"] 176 | assert error["path"] == ["dummy"] 177 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # User Guide 2 | 3 | ## Creating an application 4 | 5 | The main piece of API that `tartiflette-asgi` brings is `TartifletteApp`, an ASGI3-compliant ASGI application. 6 | 7 | You can build it from either: 8 | 9 | - The Schema Definition Language (SDL): 10 | 11 | ```python 12 | # This is an SDL string, but Tartiflette supports 13 | # other formats, e.g. paths to schema files or directories. 14 | sdl = "type Query { hello: String }" 15 | 16 | app = TartifletteApp(sdl=sdl) 17 | ``` 18 | 19 | - A Tartiflette `Engine` instance: 20 | 21 | ```python 22 | from tartiflette import Engine 23 | 24 | engine = Engine(sdl=..., modules=[...]) 25 | app = TartifletteApp(engine=engine) 26 | ``` 27 | 28 | For more information on what values `sdl` and `engine` can accept, see the [Engine API reference](https://tartiflette.io/docs/api/engine). 29 | 30 | ## Routing 31 | 32 | You can define which URL path the `TartifletteApp` should be accessible at using the `path` parameter. 33 | 34 | It is served at `/` by default, but a popular choice is to serve it at `/graphql`: 35 | 36 | ```python 37 | app = TartifletteApp(..., path="/graphql") 38 | ``` 39 | 40 | ## Making requests 41 | 42 | `tartiflette-asgi` allows you to pass the query in several ways: 43 | 44 | - URL query string (methods: `GET`, `POST`): 45 | 46 | ```bash 47 | curl 'http://localhost:8000/graphql?query=\{hello(name:"Chuck")\}' 48 | ``` 49 | 50 | - JSON-encoded body (methods: `POST`): 51 | 52 | ```bash 53 | curl http://localhost:8000/graphql \ 54 | -d '{"query": "{ hello(name: \"Chuck\") }"}' \ 55 | -H "Content-Type: application/json" 56 | ``` 57 | 58 | - Raw body with the `application/graphql` content type (methods: `POST`): 59 | 60 | ```bash 61 | curl http://localhost:8000/graphql \ 62 | -d '{ hello(name: "Chuck") }' \ 63 | -H "Content-Type: application/graphql" 64 | ``` 65 | 66 | ## GraphiQL client 67 | 68 | ### Default behavior 69 | 70 | When you access the GraphQL endpoint in a web browser, `TartifletteApp` serves a [GraphiQL](https://github.com/graphql/graphiql) client, which allows you to make interactive GraphQL queries in the browser. 71 | 72 | ![](https://github.com/tartiflette/tartiflette-asgi/raw/master/img/graphiql.png) 73 | 74 | ### Customization 75 | 76 | You can customize the GraphiQL interface using `TartifletteApp(graphiql=GraphiQL(...))`. 77 | 78 | For example, this snippet will: 79 | 80 | - Serve the GraphiQL web interface at `/graphiql`. 81 | - Send an `Authorization` header when making requests to the API endpoint. 82 | - Setup the default variables and query to show when accessing the web interface for the first time. 83 | 84 | ```python 85 | from tartiflette_asgi import TartifletteApp, GraphiQL 86 | 87 | sdl = "type Query { hello(name: String): String }" 88 | 89 | graphiql = GraphiQL( 90 | path="/graphiql", 91 | default_headers={"Authorization": "Bearer 123"}, 92 | default_variables={"name": "world"}, 93 | default_query=""" 94 | query Hello($name: String) { 95 | hello(name: $name) 96 | } 97 | """, 98 | ) 99 | 100 | app = TartifletteApp(sdl=sdl, graphiql=graphiql) 101 | ``` 102 | 103 | If you run this application, you should see the customized GraphiQL client when accessing [http://localhost:8000/graphiql](http://localhost:8000/graphiql): 104 | 105 | ![](https://raw.githubusercontent.com/tartiflette/tartiflette-asgi/master/img/graphiql-custom.png) 106 | 107 | For the full list of options, see [`GraphiQL`](/api/#graphiql). 108 | 109 | ### Disabling the GraphiQL client 110 | 111 | To disable the GraphiQL client altogether, use `TartifletteApp(graphiql=False)`. 112 | 113 | ## ASGI sub-mounting 114 | 115 | You can mount a `TartifletteApp` instance as a sub-route of another ASGI application. 116 | 117 | This is useful to have a GraphQL endpoint _and_ other (non-GraphQL) endpoints within a single application. For example, you may want to have a REST endpoint at `/api/users`, or serve an HTML page at `/index.html`, as well as expose a GraphQL endpoint at `/graphql`. 118 | 119 | How to achieve this depends on the specific ASGI web framework you are using, so this section documents how to achieve it in various situations. 120 | 121 | ### General approach 122 | 123 | In general, you'll need to do the following: 124 | 125 | 1. Create a `TartifletteApp` application instance. 126 | 1. Mount it under the main ASGI application's router. (Most ASGI frameworks expose a method such as `.mount()` for this purpose.) 127 | 1. Register the startup lifespan event handler on the main ASGI application. (Frameworks typically expose a method such as `.add_event_handler()` for this purpose.) 128 | 129 | !!! important 130 | The startup event handler is responsible for preparing the GraphQL engine (a.k.a. [cooking the engine](https://tartiflette.io/docs/api/engine#cook-your-tartiflette)), e.g. loading modules, SDL files, etc. 131 | 132 | If your ASGI framework does not implement the lifespan protocol and/or does not allow to register custom lifespan event handlers, or if you're working at the raw ASGI level, you can still use `tartiflette-asgi` but you'll need to add lifespan support yourself, e.g. using [asgi-lifespan](https://github.com/florimondmanca/asgi-lifespan). 133 | 134 | ### Routing 135 | 136 | Although `TartifletteApp` has minimal support for [routing](#routing), when using ASGI sub-mounting you'll probably want to leave the `path` parameter on `TartifletteApp` empty, e.g. use: 137 | 138 | ```python 139 | graphql = TartifletteApp(sdl=...) 140 | app.mount("/graphql", app=graphql) 141 | ``` 142 | 143 | This is because `path` is relative to the mount path on the host ASGI application. As a result, mounting at `/graphql` while setting `path="/graphql"` would make the GraphQL API accessible at `/graphql/graphql`, which is typically not what you want. 144 | 145 | If you want to have your GraphQL API accessible at `/graphql`, you should do as above, i.e.: 146 | 147 | - Leave `path` empty on `TartifletteApp`. 148 | - Mount it at `/graphql` on the host ASGI app. 149 | 150 | (Mounting at `/` and setting `path="/graphql"` typically won't have the behavior you'd expect. For example, Starlette would send all requests to your GraphQL endpoint, regardless of whether the requested URL path.) 151 | 152 | ### Examples 153 | 154 | This section documents how to mount a `TartifletteApp` instance under an ASGI application for various ASGI web frameworks. 155 | 156 | To run an example: 157 | 158 | - Save the application script as `graphql.py`. 159 | - Run the server using `$ uvicorn graphql:app`. 160 | - Make a request using: 161 | 162 | ```bash 163 | curl http://localhost:8000/graphql -d '{ hello(name: "Chuck") }' -H "Content-Type: application/graphql" 164 | ``` 165 | 166 | #### Starlette 167 | 168 | ```python 169 | from starlette.applications import Starlette 170 | from starlette.responses import PlainTextResponse 171 | from starlette.routing import Mount, Route 172 | from tartiflette import Resolver 173 | from tartiflette_asgi import TartifletteApp 174 | 175 | 176 | # Create a 'TartifletteApp' instance. 177 | 178 | @Resolver("Query.hello") 179 | async def hello(parent, args, context, info): 180 | name = args["name"] 181 | return f"Hello, {name}!" 182 | 183 | sdl = "type Query { hello(name: String): String }" 184 | graphql = TartifletteApp(sdl=sdl) 185 | 186 | # Declare regular routes as seems fit... 187 | 188 | async def home(request): 189 | return PlainTextResponse("Hello, world!") 190 | 191 | # Create a Starlette application, mounting the 'TartifletteApp' instance. 192 | 193 | routes = [ 194 | Route("/", endpoint=home), 195 | Mount("/graphql", graphql), 196 | ] 197 | app = Starlette(routes=routes, on_startup=[graphql.startup]) 198 | ``` 199 | 200 | ## Advanced usage 201 | 202 | ### Accessing request information 203 | 204 | All the information about the current HTTP request (URL, headers, query parameters, etc) is available on `context["req"]`, which returns a Starlette `Request` object representing the current HTTP request. 205 | 206 | ```python 207 | @Resolver("Query.whoami") 208 | async def resolve_whoami(parent, args, context, info): 209 | request = context["req"] 210 | who = request.query_params.get("username", "unknown") 211 | return who 212 | ``` 213 | 214 | For detailed usage notes about the `Request` object, see [Requests](https://www.starlette.io/requests/) in the Starlette documentation. 215 | 216 | ### Shared GraphQL context 217 | 218 | If you need to make services, functions or data available to GraphQL resolvers, you can use `TartifletteApp(context=...)`. Contents of the `context` argument will be merged into the GraphQL `context` passed to resolvers. 219 | 220 | For example: 221 | 222 | ```python 223 | import os 224 | from tartiflette import Resolver 225 | from tartiflette_asgi import TartifletteApp 226 | 227 | @Resolver("Query.human") 228 | async def resolve_human(parent, args, context, info): 229 | planet = context["planet"] 230 | return f"Human living on {planet}" 231 | 232 | app = TartifletteApp( 233 | sdl="type Query { human(): String }", 234 | context={"planet": os.getenv("PLANET", "Earth")}, 235 | ) 236 | ``` 237 | 238 | ### WebSocket subscriptions 239 | 240 | This package provides support for [GraphQL subscriptions](https://graphql.org/blog/subscriptions-in-graphql-and-relay/) over WebSocket. Subscription queries can be issued via the built-in GraphiQL client, as well as [Apollo GraphQL](https://www.apollographql.com/docs/react/advanced/subscriptions/) and any other client that uses the [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md) protocol. 241 | 242 | Example: 243 | 244 | ```python 245 | import asyncio 246 | from tartiflette import Subscription 247 | from tartiflette_asgi import TartifletteApp, GraphiQL 248 | 249 | sdl = """ 250 | type Query { 251 | _: Boolean 252 | } 253 | 254 | type Subscription { 255 | timer(seconds: Int!): Timer 256 | } 257 | 258 | enum Status { 259 | RUNNING 260 | DONE 261 | } 262 | 263 | type Timer { 264 | remainingTime: Int! 265 | status: Status! 266 | } 267 | """ 268 | 269 | @Subscription("Subscription.timer") 270 | async def on_timer(parent, args, context, info): 271 | seconds = args["seconds"] 272 | for i in range(seconds): 273 | yield {"timer": {"remainingTime": seconds - i, "status": "RUNNING"}} 274 | await asyncio.sleep(1) 275 | yield {"timer": {"remainingTime": 0, "status": "DONE"}} 276 | 277 | app = TartifletteApp( 278 | sdl=sdl, 279 | subscriptions=True, 280 | graphiql=GraphiQL( 281 | default_query=""" 282 | subscription { 283 | timer(seconds: 5) { 284 | remainingTime 285 | status 286 | } 287 | } 288 | """ 289 | ), 290 | ) 291 | ``` 292 | 293 | > **Note**: the subscriptions endpoint is exposed on `/subscriptions` by default. 294 | 295 | Save this file as `graphql.py`, then run `$ uvicorn graphql:app`. Open the GraphiQL client at http://localhost:8000, and hit "Play"! The timer should update on real-time. 296 | 297 | ![](https://github.com/tartiflette/tartiflette-asgi/raw/master/img/graphiql-subscriptions.png) 298 | 299 | See [`Subscriptions`](/api/#subscriptions) in the API reference for a complete description of the available options. 300 | 301 | For more information on using subscriptions in Tartiflette, see the [Tartiflette documentation](https://tartiflette.io/docs/api/subscription). 302 | --------------------------------------------------------------------------------