├── requirements.txt ├── MANIFEST.in ├── example ├── sample_app │ ├── controller │ │ ├── __init__.py │ │ ├── sample_controller_2.py │ │ └── sample_controller.py │ └── app.py └── sample_app_with_auto_import │ ├── libs │ ├── __init__.py │ └── sample_parent_controller.py │ ├── controller │ ├── __init__.py │ └── sample_controller.py │ └── app.py ├── fastapi_router_controller ├── lib │ ├── __init__.py │ ├── exceptions.py │ ├── controller_loader.py │ └── controller.py └── __init__.py ├── .coveragerc ├── pyproject.toml ├── swagger_ui.png ├── swagger_ui_basic_auth.png ├── pub.sh ├── .flake8 ├── .gitignore ├── .pre-commit-config.yaml ├── .github └── workflows │ ├── lint.yml │ ├── ci.yml │ └── on_tag_release.yml ├── LICENSE.txt ├── setup.py ├── tests ├── test_class_dependencies.py ├── test_inherit.py └── test_controller.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.63.0 -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt -------------------------------------------------------------------------------- /example/sample_app/controller/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_router_controller/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/sample_app_with_auto_import/libs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | fastapi_router_controller/lib/controller_loader.py 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py36', 'py37', 'py38'] -------------------------------------------------------------------------------- /swagger_ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiraPC/fastapi-router-controller/HEAD/swagger_ui.png -------------------------------------------------------------------------------- /swagger_ui_basic_auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiraPC/fastapi-router-controller/HEAD/swagger_ui_basic_auth.png -------------------------------------------------------------------------------- /pub.sh: -------------------------------------------------------------------------------- 1 | rm -rf .eggs/ 2 | rm -rf fastapi_router_controller.egg-info/ 3 | rm -rf dist/ 4 | 5 | git pull 6 | 7 | python setup.py sdist 8 | twine upload dist/* -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | max-complexity = 10 4 | exclude = 5 | .git, 6 | __pycache__, 7 | docs/source/conf.py, 8 | old, 9 | build, 10 | dist 11 | src/gunicorn_conf.py 12 | src/catalog/migration.py 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/* 2 | /dist/* 3 | /swagger_confluence_pub.egg-info/* 4 | /venv/* 5 | **/.DS_Store 6 | **/*.pyc 7 | .vscode/* 8 | /.eggs/* 9 | /docs/build/* 10 | /test-reports/* 11 | /_build/* 12 | /_static/* 13 | /_templates/* 14 | /__pycache__/* 15 | /*.egg-info/ -------------------------------------------------------------------------------- /example/sample_app_with_auto_import/controller/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from fastapi_router_controller import ControllerLoader 3 | 4 | this_dir = os.path.dirname(__file__) 5 | 6 | # load all the module inside the given path 7 | ControllerLoader.load(this_dir, __package__) 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 21.5b1 4 | hooks: 5 | - id: black 6 | args: [ 7 | "fastapi_router_controller" 8 | ] 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v2.0.0 11 | hooks: 12 | - id: flake8 -------------------------------------------------------------------------------- /fastapi_router_controller/lib/exceptions.py: -------------------------------------------------------------------------------- 1 | class MultipleRouterException(Exception): 2 | def __init__(self): 3 | super().__init__("Router already used by another Controller") 4 | 5 | 6 | class MultipleResourceException(Exception): 7 | def __init__(self): 8 | super().__init__("Controller already used by another Resource") 9 | -------------------------------------------------------------------------------- /fastapi_router_controller/__init__.py: -------------------------------------------------------------------------------- 1 | """FastAPI Router Contoller, FastAPI utility to allow Controller Class usage""" 2 | 3 | from fastapi_router_controller.lib.controller import Controller as Controller 4 | from fastapi_router_controller.lib.controller import OPEN_API_TAGS as ControllersTags 5 | from fastapi_router_controller.lib.controller_loader import ( 6 | ControllerLoader as ControllerLoader, 7 | ) 8 | 9 | __all__ = ["Controller", "ControllersTags", "ControllerLoader"] 10 | -------------------------------------------------------------------------------- /example/sample_app/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi_router_controller import ControllersTags 3 | from controller.sample_controller import SampleController 4 | from controller.sample_controller_2 import AnotherSampleController 5 | 6 | app = FastAPI( 7 | title="A sample application using fastapi_router_controller", 8 | version="0.1.0", 9 | openapi_tags=ControllersTags, 10 | ) 11 | 12 | app.include_router(SampleController.router()) 13 | app.include_router(AnotherSampleController.router()) 14 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: [ pull_request ] 3 | 4 | jobs: 5 | lint: 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | python-version: [ 3.9 ] 10 | os: [ ubuntu-18.04 ] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-python@v2 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: flake8 install 18 | run: pip3 install flake8 19 | - name: lint 20 | run: flake8 fastapi_router_controller/ -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: [ pull_request ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Python 3.8 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: 3.8 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | pip install -e .[tests] 18 | - name: Test with pytest 19 | run: | 20 | pytest tests --cov fastapi_router_controller --cov-report term --cov-report html 21 | -------------------------------------------------------------------------------- /example/sample_app_with_auto_import/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi_router_controller import Controller, ControllersTags 3 | 4 | # just import the main package to load all the controllers in 5 | import controller # noqa 6 | 7 | app = FastAPI( 8 | title="A sample application using fastapi_router_controller" 9 | + "with controller auto import", 10 | version="0.1.0", 11 | # all of the router openapi_tags are collected in ControllerTags object 12 | openapi_tags=ControllersTags, 13 | ) 14 | 15 | for router in Controller.routers(): 16 | app.include_router(router) 17 | -------------------------------------------------------------------------------- /example/sample_app_with_auto_import/libs/sample_parent_controller.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, status 2 | from fastapi_router_controller import Controller 3 | from fastapi.responses import JSONResponse 4 | 5 | router = APIRouter() 6 | 7 | controller = Controller(router) 8 | 9 | 10 | # This is a Sample Controller that can be exteded by another to inherit its routes 11 | @controller.resource() 12 | class SampleParentController: 13 | @controller.route.get( 14 | "/parent_api", 15 | summary="A sample API from the extended class," 16 | + "it will inherit the basepat of the child router", 17 | ) 18 | def sample_parent_api(_): 19 | return JSONResponse( 20 | status_code=status.HTTP_200_OK, content={"message": "I'm the parent"} 21 | ) 22 | -------------------------------------------------------------------------------- /fastapi_router_controller/lib/controller_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import importlib 3 | 4 | 5 | class ControllerLoader: 6 | """ 7 | The ControllerLoader class. 8 | """ 9 | 10 | @staticmethod 11 | def load(directory, package): 12 | """ 13 | It is an utility to load automatically all the python 14 | module presents on a given directory 15 | """ 16 | for module in os.listdir(directory): 17 | sub_dir = directory + "/" + module 18 | if os.path.isdir(sub_dir): 19 | ControllerLoader.load(sub_dir, "{}.{}".format(package, module)) 20 | if module == "__init__.py" or module[-3:] != ".py": 21 | continue 22 | else: 23 | module_import_name = "{}.{}".format(package, module[:-3]) 24 | importlib.import_module(module_import_name) 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2018 YOUR NAME 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/on_tag_release.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Publish Release 4 | 5 | # Controls when the action will run. 6 | on: 7 | push: 8 | # Pattern matched against refs/tags 9 | tags: 10 | - '*' 11 | 12 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 13 | jobs: 14 | # This workflow contains a single job called "build" 15 | pypi-release: 16 | # The type of runner that the job will run on 17 | runs-on: ubuntu-latest 18 | 19 | # Steps represent a sequence of tasks that will be executed as part of the job 20 | steps: 21 | - uses: actions/checkout@master 22 | - uses: actions/setup-python@v2 23 | with: 24 | python-version: '3.8' 25 | - name: Get Tag Version 26 | id: vars 27 | run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} 28 | - name: Create Distribution Package 29 | env: 30 | RELEASE_VERSION: ${{ steps.vars.outputs.tag }} 31 | run: sed -i -e "s/__VERSION__/$RELEASE_VERSION/g" setup.py && python setup.py sdist 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@release/v1 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | :author: Pasquale Carmine Carbone 3 | 4 | Setup module 5 | """ 6 | import setuptools 7 | 8 | with open("README.md", "r") as fh: 9 | LONG_DESCRIPTION = fh.read() 10 | 11 | with open("requirements.txt", "r") as fin: 12 | REQS = fin.read().splitlines() 13 | 14 | VERSION = "__VERSION__" 15 | 16 | setuptools.setup( 17 | version=VERSION, 18 | name="fastapi-router-controller", 19 | author="Pasquale Carmine Carbone", 20 | author_email="pasqualecarmine.carbone@gmail.com", 21 | description="A FastAPI utility to allow Controller Class usage", 22 | long_description=LONG_DESCRIPTION, 23 | url="https://github.com/KiraPC/fastapi-router-controller", 24 | long_description_content_type="text/markdown", 25 | packages=setuptools.find_packages( 26 | exclude=["venv", "fastapi-router-controller.egg-info", "build"] 27 | ), 28 | classifiers=[ 29 | "Programming Language :: Python :: 3", 30 | "License :: OSI Approved :: MIT License", 31 | "Operating System :: OS Independent", 32 | "Topic :: Software Development :: Libraries :: Python Modules", 33 | "Topic :: Software Development :: Libraries", 34 | "Topic :: Software Development", 35 | "Typing :: Typed", 36 | ], 37 | python_requires=">=3.6", 38 | install_requires=REQS, 39 | extras_require={"tests": ["pytest", "pytest-cov", "requests"]}, 40 | ) 41 | -------------------------------------------------------------------------------- /example/sample_app/controller/sample_controller_2.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, status, Query, Body 2 | from fastapi_router_controller import Controller 3 | from fastapi.responses import JSONResponse 4 | from pydantic import BaseModel, Field 5 | 6 | router = APIRouter(prefix="/sample_controller_2") 7 | 8 | controller = Controller( 9 | router, 10 | openapi_tag={ 11 | "name": "sample_controller_2", 12 | }, 13 | ) 14 | 15 | 16 | class SampleObject(BaseModel): 17 | id: str = Field(..., description="sample id") 18 | 19 | 20 | # With the "resource" decorator define the controller Class 21 | # linked to the Controller router arguments 22 | @controller.resource() 23 | class AnotherSampleController: 24 | @controller.route.get( 25 | "/", tags=["sample_controller_2"], summary="return another sample object" 26 | ) 27 | def sample_get_request( 28 | _, 29 | id: str = Query(..., title="itemId", description="The id of the sample object"), 30 | ): 31 | return JSONResponse(status_code=status.HTTP_200_OK, content=SampleObject(id)) 32 | 33 | @controller.route.post( 34 | "/", 35 | tags=["sample_controller_2"], 36 | summary="create a sample object", 37 | status_code=201, 38 | ) 39 | def sample_post_request( 40 | _, 41 | simple_object: SampleObject = Body( 42 | None, title="SampleObject", description="A sample object model" 43 | ), 44 | ): 45 | return JSONResponse(status_code=status.HTTP_201_CREATED, content={}) 46 | -------------------------------------------------------------------------------- /tests/test_class_dependencies.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from fastapi import Depends, FastAPI, HTTPException, APIRouter 3 | from fastapi_router_controller import Controller 4 | from fastapi.testclient import TestClient 5 | 6 | 7 | router = APIRouter() 8 | controller = Controller(router, openapi_tag={"name": "sample_controller"}) 9 | 10 | 11 | def user_exists(user_id: int): 12 | if user_id <= 5: 13 | raise HTTPException(status_code=400, detail="No User") 14 | 15 | 16 | def user_is_id(user_id: int): 17 | if user_id == 6: 18 | raise HTTPException(status_code=400, detail="Not exact user") 19 | 20 | 21 | @controller.resource() 22 | class User: 23 | dependencies = [Depends(user_exists)] 24 | 25 | @controller.route.get("/users/{user_id}", dependencies=[Depends(user_is_id)]) 26 | def read_users(self, user_id: int): 27 | return {"user_id": user_id} 28 | 29 | 30 | def create_app(): 31 | app = FastAPI() 32 | 33 | app.include_router(User.router()) 34 | return app 35 | 36 | 37 | class TestRoutes(unittest.TestCase): 38 | def setUp(self): 39 | app = create_app() 40 | self.client = TestClient(app) 41 | 42 | def test_class_dep(self): 43 | response = self.client.get("/users/1") 44 | self.assertEqual(response.status_code, 400) 45 | self.assertEqual(response.json(), {"detail": "No User"}) 46 | 47 | def test_func_dep(self): 48 | response = self.client.get("/users/6") 49 | self.assertEqual(response.status_code, 400) 50 | self.assertEqual(response.json(), {"detail": "Not exact user"}) 51 | 52 | def test_pass(self): 53 | response = self.client.get("/users/7") 54 | self.assertEqual(response.status_code, 200) 55 | self.assertEqual(response.json(), {"user_id": 7}) 56 | -------------------------------------------------------------------------------- /example/sample_app_with_auto_import/controller/sample_controller.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, status, Query, Body 2 | from fastapi_router_controller import Controller 3 | from fastapi.responses import JSONResponse 4 | from pydantic import BaseModel, Field 5 | 6 | from libs.sample_parent_controller import SampleParentController 7 | 8 | router = APIRouter(prefix="/sample_extended_controller") 9 | 10 | controller = Controller( 11 | router, 12 | openapi_tag={ 13 | "name": "sample_extended_controller", 14 | }, 15 | ) 16 | 17 | 18 | class SampleObject(BaseModel): 19 | id: str = Field(..., description="sample id") 20 | 21 | def to_json(self): 22 | return {"id": self.id} 23 | 24 | 25 | # With the "use" decorator the lib save the Controller Class to load it automatically 26 | @controller.use() 27 | # With the "resource" decorator define the controller Class 28 | # linked to the Controller router arguments 29 | @controller.resource() 30 | class SampleController(SampleParentController): 31 | @controller.route.get( 32 | "/", tags=["sample_extended_controller"], summary="return a sample object" 33 | ) 34 | def sample_get_request( 35 | self, 36 | id: str = Query(..., title="itemId", description="The id of the sample object"), 37 | ): 38 | return JSONResponse( 39 | status_code=status.HTTP_200_OK, content=SampleObject(**{"id": id}).to_json() 40 | ) 41 | 42 | @controller.route.post( 43 | "/", 44 | tags=["sample_extended_controller"], 45 | summary="create another sample object", 46 | status_code=201, 47 | ) 48 | def sample_post_request( 49 | self, 50 | simple_object: SampleObject = Body( 51 | None, title="SampleObject", description="A sample object model" 52 | ), 53 | ): 54 | return JSONResponse(status_code=status.HTTP_201_CREATED, content={}) 55 | -------------------------------------------------------------------------------- /example/sample_app/controller/sample_controller.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, status, Query, Body, Depends 2 | from fastapi_router_controller import Controller 3 | from fastapi.responses import JSONResponse 4 | from pydantic import BaseModel, Field 5 | 6 | router = APIRouter(prefix="/sample_controller") 7 | 8 | controller = Controller( 9 | router, 10 | openapi_tag={ 11 | "name": "sample_controller", 12 | }, 13 | ) 14 | 15 | 16 | class SampleObject(BaseModel): 17 | id: str = Field(..., description="sample id") 18 | 19 | def to_json(self): 20 | return {"id": self.id} 21 | 22 | 23 | class Foo: 24 | def create_foo(_, item): 25 | print("Created Foo", str(item)) 26 | 27 | 28 | def get_foo(): 29 | return Foo() 30 | 31 | 32 | # With the "resource" decorator define the controller Class 33 | # linked to the Controller router arguments 34 | @controller.resource() 35 | class SampleController: 36 | def __init__(self, foo: Foo = Depends(get_foo)): 37 | self.foo = foo 38 | 39 | @controller.route.get( 40 | "/", tags=["sample_controller"], summary="return a sample object" 41 | ) 42 | def sample_get_request( 43 | self, 44 | id: str = Query(..., title="itemId", description="The id of the sample object"), 45 | ): 46 | return JSONResponse( 47 | status_code=status.HTTP_200_OK, content=SampleObject(**{"id": id}).to_json() 48 | ) 49 | 50 | @controller.route.post( 51 | "/", 52 | tags=["sample_controller"], 53 | summary="create another sample object", 54 | status_code=201, 55 | ) 56 | def sample_post_request( 57 | self, 58 | simple_object: SampleObject = Body( 59 | {}, title="SampleObject", description="A sample object model" 60 | ), 61 | ): 62 | self.foo.create_foo(simple_object) 63 | return JSONResponse(status_code=status.HTTP_201_CREATED, content={}) 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-router-controller 2 | 3 | [![Build](https://github.com/KiraPC/fastapi-router-controller/workflows/fastapi-router-controller/badge.svg)](https://github.com/KiraPC/fastapi-router-controller) 4 | [![PyPI version fury.io](https://badge.fury.io/py/fastapi-router-controller.svg)](https://pypi.python.org/pypi/fastapi-router-controller) 5 | 6 | #### A FastAPI utility to allow Controller Class usage 7 | 8 | ## Installation: 9 | 10 | install the package 11 | ``` 12 | pip install fastapi-router-controller 13 | ``` 14 | 15 | ## How to use 16 | 17 | Here we see a Fastapi CBV (class based view) application 18 | with class wide Basic Auth dependencies. 19 | 20 | ```python 21 | import uvicorn 22 | 23 | from pydantic import BaseModel 24 | from fastapi_router_controller import Controller 25 | from fastapi import APIRouter, Depends, FastAPI, HTTPException, status 26 | from fastapi.security import HTTPBasic, HTTPBasicCredentials 27 | 28 | router = APIRouter() 29 | controller = Controller(router) 30 | security = HTTPBasic() 31 | 32 | 33 | def verify_auth(credentials: HTTPBasicCredentials = Depends(security)): 34 | correct_username = credentials.username == "john" 35 | correct_password = credentials.password == "silver" 36 | if not (correct_username and correct_password): 37 | raise HTTPException( 38 | status_code=status.HTTP_401_UNAUTHORIZED, 39 | detail="Incorrect auth", 40 | headers={"WWW-Authenticate": "Basic"}, 41 | ) 42 | return credentials.username 43 | 44 | 45 | class Foo(BaseModel): 46 | bar: str = "wow" 47 | 48 | 49 | async def amazing_fn(): 50 | return Foo(bar="amazing_variable") 51 | 52 | 53 | @controller.resource() 54 | class ExampleController: 55 | 56 | # add class wide dependencies e.g. auth 57 | dependencies = [Depends(verify_auth)] 58 | 59 | # you can define in the Controller init some FastApi Dependency and them are automatically loaded in controller methods 60 | def __init__(self, x: Foo = Depends(amazing_fn)): 61 | self.x = x 62 | 63 | @controller.route.get( 64 | "/some_api", summary="A sample description", response_model=Foo 65 | ) 66 | def sample_api(self): 67 | print(self.x.bar) # -> amazing_variable 68 | return self.x 69 | 70 | 71 | # Load the controller to the main FastAPI app 72 | 73 | app = FastAPI( 74 | title="A sample application using fastapi_router_controller", version="0.1.0" 75 | ) 76 | 77 | app.include_router(ExampleController.router()) 78 | 79 | uvicorn.run(app, host="0.0.0.0", port=9090) 80 | ``` 81 | 82 | ### Screenshot 83 | 84 | All you expect from Fastapi 85 | 86 | ![Swagger UI](./swagger_ui.png?raw=true) 87 | 88 | Also the login dialog 89 | 90 | ![Swagger UI Login](./swagger_ui_basic_auth.png?raw=true) 91 | 92 | 93 | ## For some Example use-cases visit the example folder 94 | -------------------------------------------------------------------------------- /tests/test_inherit.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pydantic import BaseModel 4 | from fastapi_router_controller import Controller, ControllersTags 5 | from fastapi import APIRouter, Depends, FastAPI, Query 6 | from fastapi.testclient import TestClient 7 | 8 | parent_router = APIRouter() 9 | child_router = APIRouter() 10 | 11 | parent_controller = Controller(parent_router, openapi_tag={"name": "parent"}) 12 | child_controller = Controller(child_router, openapi_tag={"name": "child"}) 13 | 14 | 15 | class Object(BaseModel): 16 | id: str 17 | 18 | 19 | def get_x(): 20 | class Foo: 21 | def create(self): 22 | return "XXX" 23 | 24 | return Foo() 25 | 26 | 27 | class Filter(BaseModel): 28 | foo: str 29 | 30 | 31 | @parent_controller.resource() 32 | class Base: 33 | def __init__(self): 34 | self.bla = "foo" 35 | 36 | @parent_controller.route.get( 37 | "/hambu", 38 | tags=["parent"], 39 | response_model=Object, 40 | ) 41 | def hambu(self): 42 | return Object(id="hambu-%s" % self.bla) 43 | 44 | 45 | # With the 'resource' decorator define the controller 46 | # Class linked to the Controller router arguments 47 | @child_controller.resource() 48 | class Controller(Base): 49 | def __init__(self, x=Depends(get_x)): 50 | super().__init__() 51 | self.x = x 52 | 53 | @child_controller.route.get( 54 | "/", 55 | tags=["child"], 56 | summary="return a object", 57 | response_model=Object, 58 | ) 59 | def root( 60 | self, 61 | id: str = Query(..., title="itemId", description="The id of the object"), 62 | ): 63 | id += self.x.create() + self.bla 64 | return Object(id=id) 65 | 66 | 67 | def create_app(): 68 | app = FastAPI( 69 | title="A application using fastapi_router_controller", 70 | version="0.1.0", 71 | openapi_tags=ControllersTags, 72 | ) 73 | 74 | app.include_router(Controller.router()) 75 | return app 76 | 77 | 78 | class TestRoutes(unittest.TestCase): 79 | def setUp(self): 80 | app = create_app() 81 | self.client = TestClient(app) 82 | 83 | def test_root(self): 84 | response = self.client.get("/?id=12") 85 | self.assertEqual(response.status_code, 200) 86 | self.assertEqual(response.json(), {"id": "12XXXfoo"}) 87 | 88 | def test_child1(self): 89 | response = self.client.get("/hambu") 90 | self.assertEqual(response.status_code, 200) 91 | self.assertEqual(response.json(), {"id": "hambu-foo"}) 92 | 93 | 94 | class TestInvalid(unittest.TestCase): 95 | def test_invalid(self): 96 | with self.assertRaises(Exception) as ex: 97 | 98 | @parent_controller.resource() 99 | class Controller2(Base): 100 | ... 101 | 102 | self.assertEqual( 103 | str(ex.exception), "Controller already used by another Resource" 104 | ) 105 | -------------------------------------------------------------------------------- /tests/test_controller.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pydantic import BaseModel 4 | from fastapi_router_controller import Controller, ControllersTags 5 | from fastapi import APIRouter, Depends, FastAPI, Query 6 | from fastapi.testclient import TestClient 7 | 8 | 9 | class SampleObject(BaseModel): 10 | id: str 11 | 12 | 13 | def get_x(): 14 | class Foo: 15 | def create(self): 16 | return "XXX" 17 | 18 | return Foo() 19 | 20 | 21 | def get_y(): 22 | try: 23 | yield "get_y_dep" 24 | finally: 25 | print("get_y done") 26 | 27 | 28 | class Filter(BaseModel): 29 | foo: str 30 | 31 | 32 | def create_app_declerative(): 33 | router = APIRouter() 34 | controller = Controller(router, openapi_tag={"name": "sample_controller"}) 35 | 36 | # With the 'resource' decorator define the controller Class 37 | # linked to the Controller router arguments 38 | @controller.resource() 39 | class SampleController: 40 | def __init__(self, x=Depends(get_x)): 41 | self.x = x 42 | 43 | @controller.route.get( 44 | "/", 45 | tags=["sample_controller"], 46 | summary="return a sample object", 47 | response_model=SampleObject, 48 | ) 49 | def root( 50 | self, 51 | id: str = Query( 52 | ..., title="itemId", description="The id of the sample object" 53 | ), 54 | ): 55 | id += self.x.create() 56 | return SampleObject(id=id) 57 | 58 | @controller.route.post( 59 | "/hello", 60 | response_model=SampleObject, 61 | ) 62 | def hello(self, f: Filter, y=Depends(get_y)): 63 | _id = f.foo 64 | _id += y 65 | _id += self.x.create() 66 | return SampleObject(id=_id) 67 | 68 | app = FastAPI( 69 | title="A sample application using fastapi_router_controller", 70 | version="0.1.0", 71 | openapi_tags=ControllersTags, 72 | ) 73 | 74 | app.include_router(SampleController.router()) 75 | return app 76 | 77 | 78 | def create_app_imperative(): 79 | class SampleController: 80 | def __init__(self, x=Depends(get_x)): 81 | self.x = x 82 | 83 | def root( 84 | self, 85 | id: str = Query( 86 | ..., title="itemId", description="The id of the sample object" 87 | ), 88 | ): 89 | id += self.x.create() 90 | return SampleObject(id=id) 91 | 92 | def hello(self, f: Filter, y=Depends(get_y)): 93 | _id = f.foo 94 | _id += y 95 | _id += self.x.create() 96 | return SampleObject(id=_id) 97 | 98 | router = APIRouter() 99 | controller = Controller(router, openapi_tag={"name": "sample_controller"}) 100 | 101 | controller.add_resource(SampleController) 102 | 103 | controller.route.add_api_route( 104 | "/", 105 | SampleController.root, 106 | tags=["sample_controller"], 107 | summary="return a sample object", 108 | response_model=SampleObject, 109 | methods=["GET"], 110 | ) 111 | 112 | controller.route.add_api_route( 113 | "/hello", SampleController.hello, response_model=SampleObject, methods=["POST"] 114 | ) 115 | 116 | app = FastAPI( 117 | title="A sample application using fastapi_router_controller", 118 | version="0.1.0", 119 | openapi_tags=ControllersTags, 120 | ) 121 | 122 | app.include_router(SampleController.router()) 123 | return app 124 | 125 | 126 | class TestRoutesDeclerative(unittest.TestCase): 127 | def setUp(self): 128 | app = create_app_declerative() 129 | self.client = TestClient(app) 130 | 131 | def test_root(self): 132 | response = self.client.get("/?id=12") 133 | self.assertEqual(response.status_code, 200) 134 | self.assertEqual(response.json(), {"id": "12XXX"}) 135 | 136 | def test_hello(self): 137 | response = self.client.post("/hello", json={"foo": "WOW"}) 138 | self.assertEqual(response.status_code, 200) 139 | self.assertEqual(response.json(), {"id": "WOWget_y_depXXX"}) 140 | 141 | 142 | class TestRoutesImperative(unittest.TestCase): 143 | def setUp(self): 144 | app = create_app_imperative() 145 | self.client = TestClient(app) 146 | 147 | def test_root(self): 148 | response = self.client.get("/?id=12") 149 | self.assertEqual(response.status_code, 200) 150 | self.assertEqual(response.json(), {"id": "12XXX"}) 151 | 152 | def test_hello(self): 153 | response = self.client.post("/hello", json={"foo": "WOW"}) 154 | self.assertEqual(response.status_code, 200) 155 | self.assertEqual(response.json(), {"id": "WOWget_y_depXXX"}) 156 | -------------------------------------------------------------------------------- /fastapi_router_controller/lib/controller.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from copy import deepcopy 3 | from fastapi import APIRouter, Depends 4 | from fastapi_router_controller.lib.exceptions import ( 5 | MultipleResourceException, 6 | MultipleRouterException, 7 | ) 8 | 9 | OPEN_API_TAGS = [] 10 | __app_controllers__ = [] 11 | __router_params__ = [ 12 | "response_model", 13 | "status_code", 14 | "tags", 15 | "dependencies", 16 | "summary", 17 | "description", 18 | "response_description", 19 | "responses", 20 | "deprecated", 21 | "methods", 22 | "operation_id", 23 | "response_model_include", 24 | "response_model_exclude", 25 | "response_model_by_alias", 26 | "response_model_exclude_unset", 27 | "response_model_exclude_defaults", 28 | "response_model_exclude_none", 29 | "include_in_schema", 30 | "response_class", 31 | "name", 32 | "callbacks", 33 | ] 34 | 35 | 36 | class Controller: 37 | """ 38 | The Controller class. 39 | 40 | It expose some utilities and decorator functions to define a router controller class 41 | """ 42 | 43 | RC_KEY = "__router__" 44 | SIGNATURE_KEY = "__signature__" 45 | HAS_CONTROLLER_KEY = "__has_controller__" 46 | RESOURCE_CLASS_KEY = "__resource_cls__" 47 | 48 | def __init__(self, router: APIRouter, openapi_tag: dict = None) -> None: 49 | """ 50 | :param router: The FastApi router to link to the Class 51 | :param openapi_tag: An openapi object that will describe your routes 52 | in the openapi tamplate 53 | """ 54 | # Each Controller must be linked to one fastapi router 55 | if hasattr(router, Controller.HAS_CONTROLLER_KEY): 56 | raise MultipleRouterException() 57 | 58 | self.router = deepcopy(router) 59 | self.openapi_tag = openapi_tag 60 | self.cls = None 61 | 62 | if openapi_tag: 63 | OPEN_API_TAGS.append(openapi_tag) 64 | 65 | setattr(router, Controller.HAS_CONTROLLER_KEY, True) 66 | 67 | def __get_parent_routes(self, router: APIRouter): 68 | """ 69 | Private utility to get routes from an extended class 70 | """ 71 | for route in router.routes: 72 | options = {key: getattr(route, key) for key in __router_params__} 73 | 74 | # inherits child tags if presents 75 | if len(options["tags"]) == 0 and self.openapi_tag: 76 | options["tags"].append(self.openapi_tag["name"]) 77 | 78 | self.router.add_api_route(route.path, route.endpoint, **options) 79 | 80 | def add_resource(self, cls): 81 | """ 82 | Mark a class as Controller Resource 83 | """ 84 | # check if the same controller was already used for another cls (Resource) 85 | if ( 86 | hasattr(self, Controller.RESOURCE_CLASS_KEY) 87 | and getattr(self, Controller.RESOURCE_CLASS_KEY) != cls 88 | ): 89 | raise MultipleResourceException() 90 | 91 | # check if cls (Resource) was exteded from another 92 | for base in cls.__bases__: 93 | if hasattr(base, Controller.RC_KEY): 94 | self.__get_parent_routes(base.__router__) 95 | 96 | setattr(cls, Controller.RC_KEY, self.router) 97 | setattr(self, Controller.RESOURCE_CLASS_KEY, cls) 98 | cls.router = lambda: Controller.__parse_controller_router(cls) 99 | 100 | return cls 101 | 102 | def resource(self): 103 | """ 104 | A decorator function to mark a Class as a Controller 105 | """ 106 | return self.add_resource 107 | 108 | def use(_): 109 | """ 110 | A decorator function to mark a Class to be automatically 111 | loaded by the Controller 112 | """ 113 | 114 | def wrapper(cls): 115 | __app_controllers__.append(cls) 116 | return cls 117 | 118 | return wrapper 119 | 120 | @staticmethod 121 | def __parse_controller_router(cls): 122 | """ 123 | Private utility to parse the router controller property 124 | and extract the correct functions handlers 125 | """ 126 | router = getattr(cls, Controller.RC_KEY) 127 | 128 | dependencies = None 129 | if hasattr(cls, "dependencies"): 130 | dependencies = deepcopy(cls.dependencies) 131 | delattr(cls, "dependencies") 132 | 133 | for route in router.routes: 134 | # add class dependencies 135 | if dependencies: 136 | for depends in dependencies[::-1]: 137 | route.dependencies.insert(0, depends) 138 | 139 | # get the signature of the endpoint function 140 | signature = inspect.signature(route.endpoint) 141 | # get the parameters of the endpoint function 142 | signature_parameters = list(signature.parameters.values()) 143 | 144 | # replace the class instance with the itself FastApi Dependecy 145 | signature_parameters[0] = signature_parameters[0].replace( 146 | default=Depends(cls) 147 | ) 148 | 149 | # set self and after it the keyword args 150 | new_parameters = [signature_parameters[0]] + [ 151 | parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY) 152 | for parameter in signature_parameters[1:] 153 | ] 154 | 155 | new_signature = signature.replace(parameters=new_parameters) 156 | setattr(route.endpoint, Controller.SIGNATURE_KEY, new_signature) 157 | 158 | return router 159 | 160 | @staticmethod 161 | def routers(): 162 | """ 163 | It returns all the Classes marked to be used by the "use" decorator 164 | """ 165 | routers = [] 166 | 167 | for app_controller in __app_controllers__: 168 | routers.append(app_controller.router()) 169 | 170 | return routers 171 | 172 | @property 173 | def route(self) -> APIRouter: 174 | """ 175 | It returns the FastAPI router. 176 | Use it as if you are using the original one. 177 | """ 178 | return self.router 179 | --------------------------------------------------------------------------------