Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

V3 - showcases the usage of CVB in FastAPI #82

Merged
merged 15 commits into from
Mar 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/CODEOWNERS

This file was deleted.

8 changes: 2 additions & 6 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,13 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
reviewers:
- yezz123
interval: "weekly"
commit-message:
prefix: ⬆
# Python
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
reviewers:
- yezz123
interval: "weekly"
commit-message:
prefix: ⬆
4 changes: 2 additions & 2 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ jobs:
- name: Install Dependencies
run: pip install -e .[lint]
- name: Lint
run: bash Scripts/format.sh
run: bash scripts/format.sh
- name: check Static Analysis
run: bash Scripts/lint.sh
run: bash scripts/lint.sh
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: Build distribution
run: python -m build
- name: Publish
uses: pypa/gh-action-pypi-publish@v1.6.4
uses: pypa/gh-action-pypi-publish@v1.7.1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
- name: Dump GitHub context
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
timeout-minutes: 30
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]
python-version: ["3.10", "3.11"]

steps:
- uses: actions/checkout@v3
Expand All @@ -30,6 +30,6 @@ jobs:
if: steps.cache.outputs.cache-hit != 'true'
run: pip install -e .[test]
- name: Test with pytest
run: bash Scripts/test.sh
run: bash scripts/test.sh
- name: Upload coverage
uses: codecov/codecov-action@v3
21 changes: 5 additions & 16 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v4.4.0
hooks:
- id: check-added-large-files
- id: check-toml
Expand All @@ -12,33 +10,24 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/pyupgrade
rev: v3.2.2
rev: v3.3.1
hooks:
- id: pyupgrade
args:
- --py3-plus
- --keep-runtime-typing
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.138
rev: v0.0.255
hooks:
- id: ruff
args:
- --fix
- repo: https://github.com/pycqa/isort
rev: 5.10.1
rev: 5.12.0
hooks:
- id: isort
name: isort (python)
- id: isort
name: isort (cython)
types: [cython]
- id: isort
name: isort (pyi)
types: [pyi]
- repo: https://github.com/psf/black
rev: 22.10.0
rev: 23.1.0
hooks:
- id: black
ci:
autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate
196 changes: 107 additions & 89 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,112 +27,136 @@

---

This package provides classes and decorators to use FastAPI with class based routing in Python 3.8. This allows you to construct an instance of a class and have methods of that instance be route handlers for FastAPI.
As you create more complex FastAPI applications, you may find yourself frequently repeating the same dependencies in multiple related endpoints.

**Note**: This package does not support async routes with Python versions less than 3.8 due to bugs in [`inspect.iscoroutinefunction`](https://stackoverflow.com/a/52422903/1431244). Specifically, with older versions of Python `iscoroutinefunction` incorrectly returns false so async routes are not awaited. As a result, this package only supports Python versions >= 3.8.
A common question people have as they become more comfortable with FastAPI is how they can reduce the number of times they have to copy/paste the same dependency into related routes.

To get started, install the package using pip:
`fastapi_class` provides a `class-based view` decorator `@View` to help reduce the amount of boilerplate necessary when developing related routes.

```sh
pip install fastapi-class
```
> Highly inspired by [Fastapi-utils](https://fastapi-utils.davidmontague.xyz/user-guide/class-based-views/), Thanks to [@dmontagu](https://github.com/dmontagu) for the great work.

### Example
- Example:

let's imagine that this code is part of a system that manages a list of users. The `Dao` class represents a Data Access Object, which is responsible for storing and retrieving user data from a database.
```python
from fastapi import FastAPI, APIRouter, Query
from pydantic import BaseModel
from fastapi_class import View

The `UserRoutes` class is responsible for defining the routes (i.e., the URL paths) that users can access to perform various actions on the user data.
app = FastAPI()
router = APIRouter()

Here's how the code could be used in a real world scenario:
class ItemModel(BaseModel):
id: int
name: str
description: str = None

```py
import argparse

from dao import Dao
from fastapi import FastAPI

from fastapi_class.decorators import delete, get
from fastapi_class.routable import Routable


def parse_arg() -> argparse.Namespace:
"""parse command line arguments."""
parser = argparse.ArgumentParser(
description="Example of FastAPI class based routing."
)
parser.add_argument("--url", type=str, help="URL to connect to.")
parser.add_argument("--user", type=str, help="User to connect with.")
parser.add_argument("--password", type=str, help="Password to connect with.")
return parser.parse_args()


class UserRoutes(Routable):
"""Inherits from Routable."""

# Note injection here by simply passing values to the constructor. Other injection frameworks also
# supported as there's nothing special about this __init__ method.
def __init__(self, dao: Dao) -> None:
"""Constructor. The Dao is injected here."""
super().__init__()
self.__dao = Dao

@get("/user/{name}")
def get_user_by_name(self, name: str) -> str:
# Use our injected DAO instance.
return self.__dao.get_user_by_name(name)

@delete("/user/{name}")
def delete_user(self, name: str) -> None:
self.__dao.delete(name)


def main():
args = parse_arg()
# Configure the DAO per command line arguments
dao = Dao(args.url, args.user, args.password)
# Simple intuitive injection
user_routes = UserRoutes(dao)
app = FastAPI()
# router member inherited from cr.Routable and configured per the annotations.
app.include_router(user_routes.router)
@View(router)
class ItemView:
def post(self, item: ItemModel):
return item

def get(self, item_id: int = Query(..., gt=0)):
return {"item_id": item_id}

app.include_router(router)
```

## Explanation
### Response model 📦

FastAPI generally has one define routes like:
`Exception` in list need to be either function that return `fastapi.HTTPException` itself. In case of a function it is required to have all of it's arguments to be `optional`.

```py
from fastapi import FastAPI
from fastapi import FastAPI, APIRouter, HTTPException, status
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel

app = FastAPI()
from fastapi_class import View

@app.get('/echo/{x}')
def echo(x: int) -> int:
return x
app = FastAPI()
router = APIRouter()

NOT_AUTHORIZED = HTTPException(401, "Not authorized.")
NOT_ALLOWED = HTTPException(405, "Method not allowed.")
NOT_FOUND = lambda item_id="item_id": HTTPException(404, f"Item with {item_id} not found.")

class ItemResponse(BaseModel):
field: str | None = None

@view(router)
class MyView:
exceptions = {
"__all__": [NOT_AUTHORIZED],
"put": [NOT_ALLOWED, NOT_FOUND]
}

RESPONSE_MODEL = {
"put": ItemResponse
}

RESPONSE_CLASS = {
"delete": PlainTextResponse
}

def get(self):
...
def put(self):
...
def delete(self):
...

app.include_router(router)
```

**Note**: that `app` is a global. Furthermore, [FastAPI's suggested way of doing dependency injection](https://fastapi.tiangolo.com/tutorial/dependencies/classes-as-dependencies/) is handy for things like pulling values out of header in the HTTP request. However, they don't work well for more standard dependency injection scenarios where we'd like to do something like inject a Data Access Object or database connection. For that, FastAPI suggests [their parameterized dependencies](https://fastapi.tiangolo.com/advanced/advanced-dependencies/) which might look something like:
### Customized Endpoints

```py
from fastapi import FastAPI

app = FastAPI()
from fastapi import FastAPI, APIRouter, HTTPException
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel

class ValueToInject:
# Value to inject into the function.
def __init__(self, y: int) -> None:
self.y = y
from fastapi_class import View, endpoint

def __call__(self) -> int:
return self.y

to_add = ValueToInject(2)

@app.get('/add/{x}')
def add(x: int, y: Depends(to_add)) -> int:
return x + y
app = FastAPI()
router = APIRouter()

NOT_AUTHORIZED = HTTPException(401, "Not authorized.")
NOT_ALLOWED = HTTPException(405, "Method not allowed.")
NOT_FOUND = lambda item_id="item_id": HTTPException(404, f"Item with {item_id} not found.")
EXCEPTION = HTTPException(400, "Example.")

class UserResponse(BaseModel):
field: str | None = None

@View(router)
class MyView:
exceptions = {
"__all__": [NOT_AUTHORIZED],
"put": [NOT_ALLOWED, NOT_FOUND],
"edit": [EXCEPTION]
}

RESPONSE_MODEL = {
"put": UserResponse,
"edit": UserResponse
}

RESPONSE_CLASS = {
"delete": PlainTextResponse
}

def get(self):
...
def put(self):
...
def delete(self):
...
@endpoint(("PUT",), path="edit")
def edit(self):
...
```

**Note:** The `edit()` endpoint is decorated with the `@endpoint(("PUT",), path="edit")` decorator, which specifies that this endpoint should handle `PUT` requests to the `/edit` path.

## Development 🚧

### Setup environment 📦
Expand Down Expand Up @@ -162,12 +186,6 @@ You can run all the tests with:
bash scripts/test.sh
```

> Note: You can also generate a coverage report with:

```bash
bash scripts/test_html.sh
```

### Format the code 🍂

Execute the following command to apply `pre-commit` formatting:
Expand Down
9 changes: 0 additions & 9 deletions Scripts/test_html.sh

This file was deleted.

Loading