Skip to content

Commit

Permalink
Tutorial (#32)
Browse files Browse the repository at this point in the history
* Adding tutorial stub
* Adding part0 application
* Adding more structure for the base application
  • Loading branch information
rmyers committed Jan 13, 2024
1 parent edc0c3b commit 1efe68d
Show file tree
Hide file tree
Showing 27 changed files with 440 additions and 9 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ coverage.json
htmlcov
.DS_Store
reports
db.sqlite
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ setup: $(VIRTUAL_ENV) $(VIRTUAL_ENV)/.requirements-installed ## Setup local envi

clean: ## Clean your local workspace
rm -rf $(VIRTUAL_ENV)
rm -rf htmlcov
rm -rf .coverage
rm -rf htmlcov .coverage
rm -rf *.egg-info
rm -rf build dist .*_cache *.egg-info
find . -name '*.py[co]' -delete
find . -name '__pycache__' -delete

test: flake8 unit ## Run the tests (flake8, unit)

Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* [Why Cannula](#why)
* [Installation](#install)
* [Quick Start](#start)
* [Performance](#performance)
* [Examples](#examples)
* [Documentation](https://cannula.readthedocs.io/)

Expand Down Expand Up @@ -119,6 +120,37 @@ ExecutionResult(
)
```

<h2 id="performance">Performance</h2>

We try to make sure cannula is as fast as possible. While real world benchmarks are always difficult we do have a simple test that attempts to show how cannula performs against other setups.

You can view the tests in [performance](performance/test_performance.py). We have a simple function that returns data then compare the time it takes to return those results with a plan FastAPI app vs a GraphQL request. Then we try the same GraphQL request in both Cannula and Ariadne. Here is a sample of the output:

```
1000 iterations (lower is better)
test_performance.py::test_performance
performance test results:
fastapi: 0.41961031800019555
ariadne results: 1.8639117470011115
cannula results: 0.5465521310106851
PASSED
test_performance.py::test_performance_invalid_request
performance test results:
fastapi: 0.375848950992804
ariadne results: 0.8494849189883098
cannula results: 0.4427280649833847
PASSED
test_performance.py::test_performance_invalid_query
performance test results:
fastapi: 0.37241295698913746
ariadne results: 2.1828249279933516
cannula results: 0.4591125229781028
PASSED
```

As you can see Cannula is close to the raw performance of FastAPI. Granted real world results might be different as the way Cannula achieves it speed is by caching query validation results. This works best if you have a relatively fixed set of queries that are performed such as a UI that you or another team manages. If the requests are completely ad hoc like a public api then the results will not be as great.

<h2 id="examples">Examples and Documentation</h2>

* [hello world](examples/hello.py)
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**/node_modules"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**/node_modules", "**/venv"]

source_suffix = [".rst"]

Expand Down
11 changes: 6 additions & 5 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ Read More About It
.. toctree::
:maxdepth: 2

schema
resolvers
context
datasources
middleware
tutorial/index
ref/schema
ref/resolvers
ref/context
ref/datasources
ref/middleware
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
29 changes: 29 additions & 0 deletions docs/tutorial/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Tutorial
========

How to make an application with Cannula. In this tutorial we will create
a dashboard application. This will use `Cannula` and `FastAPI` as the backend
and a simple UI with `Jinja` templates. Then we will add a reactive UI with
a small set of Javascript libraries `VanJS` and `graphql-request`.

One of the great things about GraphQL schema first design is there is a ton
of tooling in place to auto generate much of the code. This can be as simple
as the base types you use or as complex as Apollo client bindings for
`React` or `Angular`.

Since `Cannula` is a Python library we'll focus more on the server-side, and
not too much on the UI. Our application will have a number of detail views
plus a dashboard to show some high level view of all the data. All the code
samples are in the git repo so you can try each step or just skip to the end
and run the full application (Chester Cheata').

Head over to :doc:`part0`

Contents
--------

.. toctree::
:maxdepth: 1

part0
part1
81 changes: 81 additions & 0 deletions docs/tutorial/part0.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
Installation and Setup
======================

.. note::

To follow along with this tutorial you'll need a few things:

* Python 3.8+
* Node 18+ (hint use nvm)
* (Optional) GNU Make

Checkout the Code
-----------------

The easiest way to follow along is to checkout the `Cannula` repo and open up
the `examples/tutorial` folders. We have the code for each section as we add
complexity to the application. You'll want to start at `part0` and move back
and forth as needed until you have mastered GraphQL::

git clone git@github.com:rmyers/cannula.git
cd cannula/examples/tutorial

We like `GNU Make` and use `Makefiles` for all our projects as it simplifies
setup especially for beginners. In this folder you'll find a `Makefile` that
has `setup` and `help` target::

make setup

.. note::

If you are on MacOS you may need to install the xcode command line tools::

xcode-select --install

This will create a virtualenv `venv` with all the dependencies we need installed:

.. literalinclude:: ../examples/tutorial/requirements.txt


Create the initial application
------------------------------

We'll call our application `dashboard` since we are not creative. This will just
need the following stucture::

dashboard/
core/
app.py # The FastAPI application
config.py # Configuration settings for the application
database.py # Database schema
part1...n/ # Remaining sections of this tutorial
templates/
index.html
__init__.py
__main__.py # Click commands to run tasks `python -m dashboard <command>`

We are going to use FastAPI since it is a very good ASGI application base. Since
it does not have any actual webserver process, we will need to serve the application
with uvicorn. `Jinja2` is used for the templates so there is a little bit of setup
we have to do in order to server the application.

To start up the application just run the command::

make run

Then open your browser and visit `http://localhost:8000`:(http://localhost:8000)

For this tutorial we have setup 100% unit test coverage. We feel like it is best to show
by example, and the best developers write tests. Maintaining 100% coverage is easier if
you start with full coverage. This can then be enforced with a single line `fail_under = 100`.
What we really like about this is that it makes CI do the dirty work of scolding developers
that do not write tests. Nobody likes that person on the team who constantly nit picks PR's.
It is best for that person to be a machine, one it is less personal, and two they can't be
bribed for a +1.

For our tests we are using pytest and a few plugins, the most important one being
`pytest-asyncio`. This plugin makes it easy to write tests for our async handlers we have
this set to `auto` mode in our configuration:

.. literalinclude:: ../examples/tutorial/setup.cfg

2 changes: 2 additions & 0 deletions docs/tutorial/part1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Setup Schema
============
43 changes: 43 additions & 0 deletions examples/tutorial/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
REQUIREMENTS := requirements.txt
SHELL := /bin/bash
VIRTUAL_ENV ?= venv

# PHONY just means this target does not make any files
.PHONY: setup clean test help

default: help

# Make sure the virtualenv exists, create it if not.
$(VIRTUAL_ENV):
python3 -m venv $(VIRTUAL_ENV)

# Check for the existence/timestamp of .reqs-installed if the
# file is missing or older than the requirements.txt this will run pip
$(VIRTUAL_ENV)/.reqs-installed: $(REQUIREMENTS)
$(VIRTUAL_ENV)/bin/pip install -r $(REQUIREMENTS)
touch $(VIRTUAL_ENV)/.reqs-installed

setup: $(VIRTUAL_ENV) $(VIRTUAL_ENV)/.reqs-installed ## Setup local environment

clean: ## Clean your local workspace
rm -rf $(VIRTUAL_ENV)
rm -rf htmlcov
rm -rf .coverage
rm -rf *.egg-info
rm -f db.sqlite
find . -name '*.py[co]' -delete

test: setup ## Test the code
DATABASE_URI="sqlite+aiosqlite:///:memory:" $(VIRTUAL_ENV)/bin/pytest --cov dashboard --cov-config=setup.cfg

format: ## Format the code with black
$(VIRTUAL_ENV)/bin/black .

run: setup ## Run the application
$(VIRTUAL_ENV)/bin/python -m dashboard run

initdb: setup ## Create database tables
$(VIRTUAL_ENV)/bin/python -m dashboard initdb

help: ## Show the available commands
@grep '^[a-zA-Z]' $(MAKEFILE_LIST) | awk -F ':.*?## ' 'NF==2 {printf " %-20s%s\n", $$1, $$2}' | sort
Empty file.
31 changes: 31 additions & 0 deletions examples/tutorial/dashboard/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import asyncio

import click
import uvicorn

from dashboard.core.app import app
from dashboard.core.database import create_tables


@click.group()
def cli(): # pragma: no cover
pass


@click.command()
def initdb(): # pragma: no cover
click.echo("Initialized the database")
loop = asyncio.get_event_loop()
loop.run_until_complete(create_tables())


@click.command()
def run(): # pragma: no cover
uvicorn.run(app)


cli.add_command(initdb)
cli.add_command(run)

if __name__ == "__main__": # pragma: no cover
cli()
Empty file.
26 changes: 26 additions & 0 deletions examples/tutorial/dashboard/core/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request

from dashboard.core.config import config
from dashboard.core.database import create_tables


@asynccontextmanager
async def lifespan(app: FastAPI):
# Make sure the database has been created
await create_tables()
# Run the app
yield
# tear down things right now there is nothing to do


app = FastAPI(
debug=config.debug,
lifespan=lifespan,
)


@app.get("/")
def home(request: Request):
return config.templates.TemplateResponse(request, "index.html")
29 changes: 29 additions & 0 deletions examples/tutorial/dashboard/core/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import pathlib
import functools

from fastapi.templating import Jinja2Templates
from pydantic_settings import BaseSettings
from sqlalchemy.ext.asyncio import create_async_engine

DASHBOARD_ROOT = pathlib.Path(__file__).parent.parent


class Config(BaseSettings):
database_uri: str = "sqlite+aiosqlite:///db.sqlite"
debug: bool = True
template_dir: str = "templates"

@functools.cached_property
def root(self):
return DASHBOARD_ROOT

@functools.cached_property
def templates(self):
return Jinja2Templates(self.root / self.template_dir)

@functools.cached_property
def engine(self):
return create_async_engine(self.database_uri)


config = Config()
31 changes: 31 additions & 0 deletions examples/tutorial/dashboard/core/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from sqlalchemy import MetaData, Table, Column, Integer, String

from dashboard.core.config import config


metadata = MetaData()


user_table = Table(
"user_account",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100), nullable=False),
Column("email", String(255), nullable=False),
Column("password", String(30), nullable=False),
)

project = Table(
"project",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, nullable=False),
Column("name", String(100), nullable=False),
Column("title", String(255)),
Column("type", String(30)),
)


async def create_tables() -> None:
async with config.engine.begin() as conn:
await conn.run_sync(metadata.create_all)

0 comments on commit 1efe68d

Please sign in to comment.