├── 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 |

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 |

3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------