Skip to content

Commit

Permalink
Merge pull request #19 from KiraPC/dev
Browse files Browse the repository at this point in the history
Dev
  • Loading branch information
KiraPC authored Apr 14, 2021
2 parents 46ccdd7 + 5985248 commit 5c97334
Show file tree
Hide file tree
Showing 9 changed files with 335 additions and 120 deletions.
71 changes: 53 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,45 +14,80 @@ pip install fastapi-router-controller

## How to use

In a Class module
Here we see a Fastapi CBV (class based view) application
with class wide Basic Auth dependencies.

```python
from fastapi import APIRouter, Depends
import uvicorn

from pydantic import BaseModel
from fastapi_router_controller import Controller
from fastapi import APIRouter, Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials

router = APIRouter()
controller = Controller(router)
security = HTTPBasic()


def verify_auth(credentials: HTTPBasicCredentials = Depends(security)):
correct_username = credentials.username == "john"
correct_password = credentials.password == "silver"
if not (correct_username and correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect auth",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username


class Foo(BaseModel):
bar: str = "wow"


async def amazing_fn():
return 'amazing_variable'
return Foo(bar="amazing_variable")


@controller.resource()
class ExampleController():
class ExampleController:

# add class wide dependencies e.g. auth
dependencies = [Depends(verify_auth)]

# you can define in the Controller init some FastApi Dependency and them are automatically loaded in controller methods
def __init__(self, x: Foo = Depends(amazing_fn)):
self.x = x

@controller.route.get(
'/some_aoi',
summary='A sample description')
"/some_aoi", summary="A sample description", response_model=Foo
)
def sample_api(self):
print(self.x) # -> amazing_variable

return 'A sample response'
```
print(self.x.bar) # -> amazing_variable
return self.x

Load the controller to the main FastAPI app
```python
from fastapi import FastAPI
from fastapi_router_controller import Controller

import ExampleController
# Load the controller to the main FastAPI app

app = FastAPI(
title='A sample application using fastapi_router_controller',
version="0.1.0")
title="A sample application using fastapi_router_controller", version="0.1.0"
)

app.include_router(ExampleController.router())

uvicorn.run(app, host="0.0.0.0", port=9090)
```

### Screenshot

All you expect from Fastapi

![Swagger UI](./swagger_ui.png?raw=true)

Also the login dialog

![Swagger UI Login](./swagger_ui_basic_auth.png?raw=true)


## For some Example use-cases visit the example folder
2 changes: 0 additions & 2 deletions fastapi_router_controller/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""FastAPI Router Contoller, FastAPI utility to allow Controller Class usage"""

__version__ = "0.1.0"

from fastapi_router_controller.lib.controller import Controller as Controller
from fastapi_router_controller.lib.controller import OPEN_API_TAGS as ControllersTags
from fastapi_router_controller.lib.controller_loader import ControllerLoader as ControllerLoader
151 changes: 92 additions & 59 deletions fastapi_router_controller/lib/controller.py
Original file line number Diff line number Diff line change
@@ -1,85 +1,106 @@
import inspect
from copy import deepcopy
from fastapi import APIRouter, Depends
from fastapi_router_controller.lib.exceptions import MultipleResourceException, MultipleRouterException

OPEN_API_TAGS = []
__app_controllers__ = []
__router_params__ = [
'response_model',
'status_code',
'tags',
'dependencies',
'summary',
'description',
'response_description',
'responses',
'deprecated',
'methods',
'operation_id',
'response_model_include',
'response_model_exclude',
'response_model_by_alias',
'response_model_exclude_unset',
'response_model_exclude_defaults',
'response_model_exclude_none',
'include_in_schema',
'response_class',
'name',
'callbacks'
]

class Controller():
'''
"response_model",
"status_code",
"tags",
"dependencies",
"summary",
"description",
"response_description",
"responses",
"deprecated",
"methods",
"operation_id",
"response_model_include",
"response_model_exclude",
"response_model_by_alias",
"response_model_exclude_unset",
"response_model_exclude_defaults",
"response_model_exclude_none",
"include_in_schema",
"response_class",
"name",
"callbacks",
]


class Controller:
"""
The Controller class.
It expose some utilities and decorator functions to define a router controller class
'''
"""
RC_KEY = '__router__'
SIGNATURE_KEY = '__signature__'
HAS_CONTROLLER_KEY = '__has_controller__'
RESOURCE_CLASS_KEY = '__resource_cls__'

def __init__(self, router: APIRouter, openapi_tag: dict = None) -> None:
'''
"""
:param router: The FastApi router to link to the Class
:param openapi_tag: An openapi object that will describe your routes in the openapi tamplate
'''
self.router = router
"""
# Each Controller must be linked to one fastapi router
if hasattr(router, Controller.HAS_CONTROLLER_KEY):
raise MultipleRouterException()

self.router = deepcopy(router)
self.openapi_tag = openapi_tag
self.cls = None

if openapi_tag:
OPEN_API_TAGS.append(openapi_tag)

setattr(router, Controller.HAS_CONTROLLER_KEY, True)

def __get_parent_routes(self, router: APIRouter):
'''
"""
Private utility to get routes from an extended class
'''
"""
for route in router.routes:
options = {key: getattr(route, key) for key in __router_params__}

# inherits child tags if presents
if len(options['tags']) == 0 and self.openapi_tag:
options['tags'].append(self.openapi_tag['name'])
if len(options["tags"]) == 0 and self.openapi_tag:
options["tags"].append(self.openapi_tag["name"])

self.router.add_api_route(route.path, route.endpoint, **options)

def resource(self):
def add_resource(self, cls):
'''
A decorator function to mark a Class as a Controller
Mark a class as Controller Resource
'''
def wrapper(cls):
# check if cls was extended from another Controller
if hasattr(cls, Controller.RC_KEY):
self.__get_parent_routes(cls.__router__)

cls.__router__ = deepcopy(self.router)
cls.router = lambda: Controller.__parse_controller_router(cls)
return cls
# check if the same controller was already used for another cls (Resource)
if hasattr(self, Controller.RESOURCE_CLASS_KEY) and getattr(self, Controller.RESOURCE_CLASS_KEY) != cls:
raise MultipleResourceException()

return wrapper
# check if cls (Resource) was exteded from another
if hasattr(cls, Controller.RC_KEY):
self.__get_parent_routes(cls.__router__)

setattr(cls, Controller.RC_KEY, self.router)
setattr(self, Controller.RESOURCE_CLASS_KEY, cls)
cls.router = lambda: Controller.__parse_controller_router(cls)

return cls

def resource(self):
"""
A decorator function to mark a Class as a Controller
"""
return self.add_resource

def use(_):
'''
"""
A decorator function to mark a Class to be automatically loaded by the Controller
'''
"""

def wrapper(cls):
__app_controllers__.append(cls)
return cls
Expand All @@ -88,47 +109,59 @@ def wrapper(cls):

@staticmethod
def __parse_controller_router(cls):
'''
"""
Private utility to parse the router controller property and extract the correct functions handlers
'''
"""
router = getattr(cls, Controller.RC_KEY)

dependencies = None
if hasattr(cls, "dependencies"):
dependencies = deepcopy(cls.dependencies)
delattr(cls, "dependencies")

for route in router.routes:
# add class dependencies
if dependencies:
for depends in dependencies[::-1]:
route.dependencies.insert(0, depends)

# get the signature of the endpoint function
signature = inspect.signature(route.endpoint)
# get the parameters of the endpoint function
signature_parameters = list(signature.parameters.values())

# replace the class instance with the itself FastApi Dependecy
signature_parameters[0] = signature_parameters[0].replace(default=Depends(cls))
signature_parameters[0] = signature_parameters[0].replace(
default=Depends(cls)
)

# set self and after it the keyword args
new_parameters = [signature_parameters[0]] + [
parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY)
for parameter in signature_parameters[1:]
]

new_signature = signature.replace(parameters=new_parameters)
setattr(route.endpoint, Controller.SIGNATURE_KEY, new_signature)

return router

@staticmethod
def routers():
'''
"""
It returns all the Classes marked to be used by the "use" decorator
'''
"""
routers = []

for app_controller in __app_controllers__:
routers.append(
app_controller.router()
)

routers.append(app_controller.router())

return routers

@property
def route(self) -> APIRouter:
'''
"""
It returns the FastAPI router.
Use it as if you are using the original one.
'''
"""
return self.router
7 changes: 7 additions & 0 deletions fastapi_router_controller/lib/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class MultipleRouterException(Exception):
def __init__(self):
super().__init__('Router already used by another Controller')

class MultipleResourceException(Exception):
def __init__(self):
super().__init__('Controller already used by another Resource')
Binary file added swagger_ui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added swagger_ui_basic_auth.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 55 additions & 0 deletions tests/test_class_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import unittest
from fastapi import Depends, FastAPI, HTTPException, APIRouter
from fastapi_router_controller import Controller
from fastapi.testclient import TestClient


router = APIRouter()
controller = Controller(router, openapi_tag={"name": "sample_controller"})


def user_exists(user_id: int):
if user_id <= 5:
raise HTTPException(status_code=400, detail="No User")


def user_is_id(user_id: int):
if user_id == 6:
raise HTTPException(status_code=400, detail="Not exact user")


@controller.resource()
class User:
dependencies = [Depends(user_exists)]

@controller.route.get("/users/{user_id}", dependencies=[Depends(user_is_id)])
def read_users(self, user_id: int):
return {"user_id": user_id}


def create_app():
app = FastAPI()

app.include_router(User.router())
return app


class TestRoutes(unittest.TestCase):
def setUp(self):
app = create_app()
self.client = TestClient(app)

def test_class_dep(self):
response = self.client.get('/users/1')
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json(), {'detail': 'No User'})

def test_func_dep(self):
response = self.client.get('/users/6')
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json(), {'detail': 'Not exact user'})

def test_pass(self):
response = self.client.get('/users/7')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {'user_id': 7})
Loading

0 comments on commit 5c97334

Please sign in to comment.