https://github.com/rockyburt/ketchup
A very basic web application and tutorial for demonstrating usage of the Quart and Strawberry GraphQL frameworks/libraries.
https://github.com/rockyburt/ketchup
asgi asyncio asyncpg poetry postgresql pytest python python3 quart sqlalchemy strawberry-graphql
Last synced: 18 days ago
JSON representation
A very basic web application and tutorial for demonstrating usage of the Quart and Strawberry GraphQL frameworks/libraries.
- Host: GitHub
- URL: https://github.com/rockyburt/ketchup
- Owner: rockyburt
- License: apache-2.0
- Created: 2021-09-18T21:06:52.000Z (over 3 years ago)
- Default Branch: main
- Last Pushed: 2021-09-26T16:34:58.000Z (over 3 years ago)
- Last Synced: 2025-03-20T20:17:59.286Z (about 1 month ago)
- Topics: asgi, asyncio, asyncpg, poetry, postgresql, pytest, python, python3, quart, sqlalchemy, strawberry-graphql
- Language: Python
- Homepage:
- Size: 154 KB
- Stars: 8
- Watchers: 1
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# Ketchup
*Ketchup* is a simple web app for learning how to build web apps using recent Python technologies
with an emphasis on asynchronous I/O (asyncio) programming.Project Source
:Part 1 and 2 Source:
:Part 3 Source:
:## Prerequisites
- A Linux or Linux-like work environment (all steps were tested and run on *Ubuntu 20.04* inside the *WSL* environment of *Windows 10*)
- A working Python 3.9 installation**NOTE**: Theoretically these steps will run (using platform/OS-equivalent steps) on most modern OS's but the developer's mileage may vary.
## `Quart` + `Strawberry-GraphQL` Tutorial
The following tutorial explains how to use *Quart* and *Strawberry-GraphQL* to build a simple web application with
a GraphQL-based API. All example code is written to heavily lean on `asyncio` based programming with a focus
on including *type hint* support in a Python setting.**Requirements:** *Python 3.9 or higher*
### Part 1: Getting Started with Quart
*Quart* is a web framework heavily inspired by the [Flask](https://flask.palletsprojects.com/en/2.0.x/) framework. It
strives to be API-compatible with Flask with method and class signatures only differing to allow for the Python `async/await`
approach to asynchronous I/O programming.1. Install Poetry via
2. Create new *Ketchup* project
```sh
poetry new Ketchup
```3. Go into the newly created `Ketchup` directory and add *Quart* to Poetry's requirements files. Also include Hypercorn for it's ASGI running expertise.
```sh
cd Ketchup
poetry add Quart hypercorn
```At the time of writing this doc, the versions installed were:
- `Quart 0.15.1`4. Setup skeleton web app:
Create new file `Ketchup/ketchup/webapp.py` with the following content:
```python
from quart import Quartapp = Quart(__name__)
@app.route('/')
async def index():
return 'Hello World'if __name__ == "__main__":
app.run()```
5. Test new application by running the following from inside the `Ketchup` project directory.
```sh
poetry run python ketchup/webapp.py
```And open up the following in your browser:
### Part 2: Adding Strawberry
*Strawberry* is a library for building GraphQL web applications on top of Python and (preferably but not limited to)
`asyncio` web frameworks. The goal is to define GraphQL schema's using the `dataclasses` package which is part
of the Python *stdlib*.1. Add *Strawberry-GraphQL* to Poetry's requirements files.
```sh
poetry add Strawberry-graphql
```At the time of writing this doc, the versions installed were:
- `Strawberry-graphql 0.77.10`
2. Create a new module at `Ketchup/ketchup/gqlschema.py`
```python
import strawberry@strawberry.type
class Query:
@strawberry.field
def upper(self, val: str) -> str:
return val.upper()
```3. Create new module for embedding the strawberry graphql endpoint as a standard *Quart* view as `Ketchup/ketchup/strawview.py`
```python
import json
import logging
import pathlib
import traceback
from typing import Any, Unionimport strawberry
from quart import Response, abort, render_template_string, request
from quart.typing import ResponseReturnValue
from quart.views import View
from strawberry.exceptions import MissingQueryError
from strawberry.file_uploads.utils import replace_placeholders_with_files
from strawberry.http import (GraphQLHTTPResponse, parse_request_data,
process_result)
from strawberry.schema import BaseSchema
from strawberry.types import ExecutionResultlogger = logging.getLogger("ketchup")
def render_graphiql_page() -> str:
dir_path = pathlib.Path(strawberry.__file__).absolute().parent
graphiql_html_file = f"{dir_path}/static/graphiql.html"html_string = None
with open(graphiql_html_file, "r") as f:
html_string = f.read()return html_string.replace("{{ SUBSCRIPTION_ENABLED }}", "false")
class GraphQLView(View):
methods = ["GET", "POST"]def __init__(self, schema: BaseSchema, graphiql: bool = True):
self.schema = schema
self.graphiql = graphiqlasync def process_result(self, result: ExecutionResult) -> GraphQLHTTPResponse:
if result.errors:
for error in result.errors:
err = getattr(error, "original_error", None) or error
formatted = "".join(traceback.format_exception(err.__class__, err, err.__traceback__))
logger.error(formatted)return process_result(result)
async def dispatch_request(self, *args: Any, **kwargs: Any) -> Union[ResponseReturnValue, str]:
if "text/html" in request.headers.get("Accept", ""):
if not self.graphiql:
abort(404)template = render_graphiql_page()
return await render_template_string(template)content_type = str(request.headers.get("content-type", ""))
if content_type.startswith("multipart/form-data"):
form = await request.form
operations = json.loads(form.get("operations", "{}"))
files_map = json.loads(form.get("map", "{}"))data = replace_placeholders_with_files(operations, files_map, await request.files)
else:
data = await request.get_json()try:
request_data = parse_request_data(data)
except MissingQueryError:
return Response("No valid query was provided for the request", 400)context = {"request": request}
result = await self.schema.execute(
request_data.query,
variable_values=request_data.variables,
context_value=context,
operation_name=request_data.operation_name,
root_value=None,
)response_data = await self.process_result(result)
return Response(
json.dumps(response_data),
status=200,
content_type="application/json",
)
```4. Modify file `Ketchup/ketchup/webapp.py` to have the following content:
```python
import asynciofrom hypercorn.asyncio import serve
from hypercorn.config import Config
from quart import Quart
from strawberry import Schemafrom ketchup.gqlschema import Query
from ketchup.strawview import GraphQLViewapp = Quart("ketchup")
schema = Schema(Query)app.add_url_rule("/graphql", view_func=GraphQLView.as_view("graphql_view", schema=schema))
@app.route("/")
async def index():
return 'Welcome to Ketchup! Please see GraphiQL to interact with the GraphQL endpoint.'def hypercorn_serve():
config = Config()
config.bind = ["0.0.0.0:5000"]
config.use_reloader = True
asyncio.run(serve(app, config, shutdown_trigger=lambda: asyncio.Future()))if __name__ == "__main__":
hypercorn_serve()
```5. Test new application by running the following from inside the `Ketchup` project directory.
a. Run the updated web app
```sh
poetry run python -m ketchup.webapp
```b. Open up the following in your browser:
c. Input the following graph query into the left side text area and hit the *play* button.
```graphql
query {
upper(val: "dude, where's my car?")
}
```The result should be (on the right side):
```json
{
"data": {
"upper": "DUDE, WHERE'S MY CAR?"
}
```### Bonus Points - Running Tests
1. Add *pytest* as a dependency.
```sh
# The ^6.2 version identifier for pytest is required due to other dependencies pulling down older versions of pytest
poetry add -D "pytest^6.2" pytest-asyncio
```2. Ensure the `Ketchup/tests/test_ketchup.py` file exists with the following content:
```python
import pytestfrom ketchup import __version__, webapp
def test_version():
assert __version__ == "0.1.0"@pytest.mark.asyncio
class TestViews:
async def test_index(self):
assert "Welcome" in (await webapp.index())
```3. Run the tests by issuing the following from *inside* the `Ketchup` directory.
```sh
poetry run pytest
```The result should be something like:
```text
=============================== test session starts ===============================
platform linux -- Python 3.9.5, pytest-5.4.3, py-1.10.0, pluggy-0.13.1
rootdir: /home/ubuntu/dev/Ketchup
plugins: anyio-3.3.1, asyncio-0.15.1
collected 2 itemstests/test_ketchup.py .. [100%]
================================ 2 passed in 0.16s ================================
```### Coding Conventions
It is the author's advice to add the following to help with formatting all code in a standard way.
- Add some developer dependencies:
```sh
poetry add -D black isort
```The standard *Python* method for activating these formatters would be to append something like the following to `Ketchup/pyproject.toml`.
```toml
exclude = '''
/(
\.git
| \.tox
| \.venv
| build
| dist
)/
'''
line-length = 119 # standard editor width used by github[tool.isort]
profile = "black"
```### Part 3a: Persistence with SQLAlchemy and PostgreSQL
This is where the project actually starts getting useful. We are building a **ToDo** application that can persist
todo records to a PostgreSQL database. The following steps assume you are within the `Ketchup` directory.1. Add *SQLAlchemy*, *alembic*, and *asyncpg* as a dependencies.
*As of the writing of this tutorial, SQLAlchemy 1.4.23 is the most recent ... with SQLAlchemy 1.4.x the first version to
include asyncio support. In addition, `asyncio` support comes only from PostgreSQL and the `asyncpg` db adapter.*```sh
poetry add sqlalchemy alembic asyncpg# for anyone using mypy/pylance/pyright/vscode ... adding the following should provide better type hint support
# but is optional and won't affect the application
poetry add -D sqlalchemy2.stubs
```2. Initialize the *Alembic* database migration framework.
```sh
poetry run alembic init --template async alembic
```3. Modify `Ketchup/alembic/env.py` so that it uses the same postgres db configuration as the rest of the web app.
The file should look like this:```python
import asyncio
from logging.config import fileConfigfrom sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_enginefrom alembic import context
from ketchup import sqlamodels, base# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config# Interpret the config file for Python logging.
# This line sets up loggers basically.
assert config.config_file_name is not None
fileConfig(config.config_file_name)# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = sqlamodels.mapper_registry.metadata# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.def run_migrations_offline():
"""Run migrations in 'offline' mode.This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.Calls to context.execute() here emit the given string to the
script output."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)with context.begin_transaction():
context.run_migrations()def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)with context.begin_transaction():
context.run_migrations()async def run_migrations_online():
"""Run migrations in 'online' mode.In this scenario we need to create an Engine
and associate a connection with the context."""
connectable = create_async_engine(
base.config.DB_URI,
future=True,
poolclass=pool.NullPool,
)async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
```4. Setup the initial *SQLAlchemy* models by creating ``Ketchup/ketchup/sqlamodels.py``.
```python
import asyncio
import dataclasses
import datetime
import typing
from contextlib import asynccontextmanagerfrom sqlalchemy import Column, DateTime, Integer, Table, Text
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_scoped_session,
create_async_engine,
)
from sqlalchemy.orm import registry, sessionmakerfrom ketchup import base
mapper_registry = registry()
_async_engine = create_async_engine(base.config.DB_URI, future=True)
_async_session_factory = sessionmaker(_async_engine, class_=AsyncSession, expire_on_commit=False) # type: ignore - having some pyright issues_get_session = async_scoped_session(_async_session_factory, scopefunc=asyncio.current_task)
@asynccontextmanager
async def atomic_session() -> typing.AsyncIterator[AsyncSession]:
async with _get_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise@mapper_registry.mapped
@dataclasses.dataclass
class Todo:
__table__ = Table(
"ketchup_todo",
mapper_registry.metadata,
Column(
"id",
Integer,
primary_key=True,
autoincrement=True,
nullable=False,
),
Column("text", Text(), nullable=False),
Column("created", DateTime(True), nullable=False),
Column("completed", DateTime(True), nullable=True),
)id: int = dataclasses.field(init=False)
text: str
created: datetime.datetime = dataclasses.field(
default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
)
completed: typing.Optional[datetime.datetime] = None
```5. Create new `Ketchup/ketchup/base.py` file with the following contents for configuration.
```python
import os
import typingclass Config:
DB_URI: str = "postgresql+asyncpg://localhost/ketchup"def __init__(self, prefix: str = "KETCHUP_"):
for name, type_ in typing.get_type_hints(self).items():
envname = prefix + name
if envname in os.environ:
setattr(self, name, type_(os.environ[envname]))config = Config()
```6. At this point, make sure PostgreSQL has beeng configured properly with an empty
database setup and referenced by either `config.DB_URI` or by setting os env variable
`KETCHUP_DB_URI`. Once that's been done, use *Alembic* to auto-generate the first revision migration file.```sh
poetry run alembic revision --autogenerate -m "New ketchup_todos table"
```7. Run the following to setup the necessary database schema.
```sh
poetry run alembic upgrade head
```### Part 3b: Making the GraphQL schema useful
1. Modify `Ketchup/ketchup/gqlschema.py` to have basic CRUD access via *Query* and *Mutation*.
```python
import datetime
import typingimport strawberry
from sqlalchemy.future import selectfrom ketchup import sqlamodels
strawberry.type(sqlamodels.Todo)
@strawberry.type
class Query:
todos: list[sqlamodels.Todo]@strawberry.field(name="todos")
async def _todos_resolver(self) -> list[sqlamodels.Todo]:
async with sqlamodels.atomic_session() as session:
items = (await session.execute(select(sqlamodels.Todo))).scalars()
todos = typing.cast(list[sqlamodels.Todo], items)
return todos@strawberry.type(description="Standard CRUD operations for todo's")
class TodoOps:
@strawberry.mutation
async def add_todo(self, text: str) -> sqlamodels.Todo:
todo = sqlamodels.Todo(text=text)
async with sqlamodels.atomic_session() as session:
session.add(todo)
return todo@strawberry.mutation
async def remove_todo(self, id: int) -> bool:
async with sqlamodels.atomic_session() as session:
item = (await session.execute(select(sqlamodels.Todo).where(sqlamodels.Todo.id == id))).scalars().first()
todo = typing.cast(typing.Optional[sqlamodels.Todo], item)if todo is None:
return Falseawait session.delete(todo)
return True@strawberry.mutation
async def set_todo_completed(self, id: int, flag: bool = True) -> typing.Optional[sqlamodels.Todo]:
async with sqlamodels.atomic_session() as session:
item = (await session.execute(select(sqlamodels.Todo).where(sqlamodels.Todo.id == id))).scalars().first()
todo = typing.cast(typing.Optional[sqlamodels.Todo], item)if todo is None:
return Nonetodo.completed = datetime.datetime.now(datetime.timezone.utc) if flag else None
return todo@strawberry.mutation
async def modify_todo_text(self, id: int, text: str) -> typing.Optional[sqlamodels.Todo]:
async with sqlamodels.atomic_session() as session:
item = (await session.execute(select(sqlamodels.Todo).where(sqlamodels.Todo.id == id))).scalars().first()
todo = typing.cast(typing.Optional[sqlamodels.Todo], item)if todo is None:
return Nonetodo.text = text
return todo@strawberry.type
class Mutation:
todos: TodoOps@strawberry.field(name="todos")
def _todos_resolver(self) -> TodoOps:
return TodoOps()schema = strawberry.Schema(query=Query, mutation=Mutation)
```2. The ``Ketchup/ketchup/webapp.py` file will need to be updated to accomodate the *SQLAlchemy* database access as well as
the new mutation support.```python
import asynciofrom hypercorn.asyncio import serve
from hypercorn.config import Config as HypercornConfig
from quart import Quartfrom ketchup import base
from ketchup.gqlschema import schema
from ketchup.strawview import GraphQLViewapp = Quart("ketchup")
app.config.from_object(base.config)app.add_url_rule("/graphql", view_func=GraphQLView.as_view("graphql_view", schema=schema))
@app.route("/")
async def index():
return 'Welcome to Ketchup! Please see GraphiQL to interact with the GraphQL endpoint.'def hypercorn_serve():
hypercorn_config = HypercornConfig()
hypercorn_config.bind = ["0.0.0.0:5000"]
hypercorn_config.use_reloader = True
asyncio.run(serve(app, hypercorn_config, shutdown_trigger=lambda: asyncio.Future()))if __name__ == "__main__":
hypercorn_serve()
```3. At this point it should be possible to restart the web application and start playing with the actual
graphql queries.```sh
poetry run python -m ketchup.webapp
```And the go to to test queries/mutations. Some examples are:
```graphql
# Query #1
# The following will show all todos persisted to the database .. upon first query it should return an empty
# result set
query {
todos {
id
text
created
completed
}
}
``````graphql
# Mutation #1
# This will create our first todo record and show ups the generated ID. After running this at least once
# it should be possible to re-run "Query #1" above and see data that was created and saved.
mutation {
todos {
addTodo(text: "Hello World") {
id
}
}
}```
### Bonus Points - Adding Query Tests
1. Add new `Ketchup/tests/conftest.py` file to setup *pytest* fixtures.
```python
import asyncio
import uuid
from urllib.parse import urlparse, urlunparseimport pytest
from sqlalchemy import pool, text
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_scoped_session,
create_async_engine,
)
from sqlalchemy.orm import close_all_sessions, sessionmakerfrom ketchup import __version__, base, sqlamodels
def test_version():
assert __version__ == "0.1.0"@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()@pytest.fixture(scope="session")
def session_monkeypatch():
mpatch = pytest.MonkeyPatch()
yield mpatch
mpatch.undo()@pytest.fixture(scope="session")
async def empty_db(session_monkeypatch: pytest.MonkeyPatch):
parsed = urlparse(base.config.DB_URI)
dbname = parsed.path[1:] + "_test_" + uuid.uuid4().hexnewuri = urlunparse([parsed[0], parsed[1], "/template1", parsed[3], parsed[4], parsed[5]])
anony_engine = create_async_engine(
newuri,
future=True,
poolclass=pool.NullPool,
isolation_level="AUTOCOMMIT",
)async with anony_engine.connect() as conn:
await (await conn.execution_options(isolation_level="AUTOCOMMIT")).execute(text(f"CREATE DATABASE {dbname}"))async_engine = None
async_session_factory = None
make_session = None
try:
newuri = urlunparse([parsed[0], parsed[1], "/" + dbname, parsed[3], parsed[4], parsed[5]])
async_engine = create_async_engine(newuri, future=True)
async_session_factory = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) # type: ignore - having some pyright issues
make_session = async_scoped_session(async_session_factory, scopefunc=asyncio.current_task)
session_monkeypatch.setattr(sqlamodels, "make_session", make_session)async with async_engine.begin() as conn:
await conn.run_sync(sqlamodels.mapper_registry.metadata.create_all)yield
finally:
close_all_sessions()
if async_engine is not None:
try:
await async_engine.dispose()
except Exception:
...
async with anony_engine.connect() as conn:
await (await conn.execution_options(isolation_level="AUTOCOMMIT")).execute(text(f"DROP DATABASE {dbname}"))
```2. Update the `Ketchup/tests/test_ketchup.py` file to have the following content.
```python
import pytestfrom ketchup import __version__, gqlschema, webapp
def test_version():
assert __version__ == "0.1.0"@pytest.mark.asyncio
class TestViews:
async def test_index(self):
assert "Welcome" in (await webapp.index())@pytest.mark.asyncio
@pytest.mark.usefixtures("empty_db")
class TestGraphQuery:
async def _create(self, text: str) -> int:
result = await gqlschema.schema.execute('mutation { todos { addTodo(text: "hello world") { id } } }')
assert result.data is not None
assert "id" in result.data["todos"]["addTodo"]newid = result.data["todos"]["addTodo"]["id"]
return newidasync def test_create_remove(self):
newid = await self._create("hello world")
result = await gqlschema.schema.execute("mutation { todos { removeTodo(id: %s) } }" % newid)
assert result.data is not None
assert result.data["todos"]["removeTodo"] == True
```3. Run the tests by issuing the following from *inside* the `Ketchup` directory.
```sh
poetry run pytest
```The result should be something like:
```text
=============================== test session starts ===============================
platform linux -- Python 3.9.5, pytest-5.4.3, py-1.10.0, pluggy-0.13.1
rootdir: /home/ubuntu/dev/Ketchup
plugins: anyio-3.3.1, asyncio-0.15.1
collected 3 itemstests/test_ketchup.py ... [100%]
================================ 3 passed in 0.36s ================================
```## Frameworks/Components Reference
[Quart](https://pgjones.gitlab.io/quart/index.html)
: An asynchronous I/O (asyncio) based web framework inspired by Flask[Strawberry GraphQL](https://strawberry.rocks/docs/integrations/asgi)
: A GraphQL library for Python enabling the building of GraphQL web apps using Python dataclasses[SQLAlchemy](https://www.sqlalchemy.org/)
: Python ORM mapper (with `asyncio` support as of v1.4).[asyncpg](https://github.com/MagicStack/asyncpg)
: A PostgreSQL client library for Python using `asyncio`.[Alembic](https://alembic.sqlalchemy.org/en/latest/)
: A database migration tool for *SQLAlchemy*.