├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── scripts │ └── releases.py └── workflows │ ├── lint.yml │ ├── pypi-publish.yml │ ├── pytest.yml │ └── update-release-docs.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs └── en │ ├── docs │ ├── assets │ │ ├── RouteDetail.png │ │ ├── RouteOverview.png │ │ ├── banner.png │ │ ├── bolt-grad.svg │ │ ├── bolt.svg │ │ └── logo.png │ ├── backends │ │ ├── async.md │ │ ├── gino.md │ │ ├── memory.md │ │ ├── ormar.md │ │ ├── sqlalchemy.md │ │ └── tortoise.md │ ├── contributing.md │ ├── css │ │ ├── main.css │ │ └── termynal.css │ ├── dependencies.md │ ├── index.md │ ├── js │ │ ├── termy.js │ │ └── termynal.js │ ├── pagination.md │ ├── releases.md │ ├── routing.md │ └── schemas.md │ ├── mkdocs.yml │ ├── overrides │ └── main.html │ └── vercel.json ├── fastapi_crudrouter ├── __init__.py ├── _version.py ├── core │ ├── __init__.py │ ├── _base.py │ ├── _types.py │ ├── _utils.py │ ├── databases.py │ ├── gino_starlette.py │ ├── mem.py │ ├── ormar.py │ ├── sqlalchemy.py │ └── tortoise.py └── py.typed ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conf ├── __init__.py ├── config.py ├── dev.docker-compose.yml └── dev.env ├── conftest.py ├── dev.requirements.txt ├── implementations ├── __init__.py ├── databases_.py ├── gino_.py ├── memory.py ├── ormar_.py ├── sqlalchemy_.py └── tortoise_.py ├── test_base.py ├── test_custom_ids.py ├── test_dependencies ├── test_disable.py ├── test_per_route.py └── test_top_level.py ├── test_exclude.py ├── test_integration ├── __init__.py ├── test_backend_not_installed.py └── test_typing.py ├── test_integrity_errors.py ├── test_openapi_schema.py ├── test_overloads.py ├── test_pagination.py ├── test_pks.py ├── test_prefix.py ├── test_router.py ├── test_sqlalchemy_nested_.py ├── test_two_routers.py ├── test_version.py └── utils.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: awtkns 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug / Issue Report 2 | description: File a bug/issue 3 | title: "🐛 " 4 | labels: [Bug, Needs Triage] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for your intrest in FastAPI-Crudrouter! 9 | - type: textarea 10 | id: description 11 | attributes: 12 | label: Description 13 | description: | 14 | What is the problem, question, or error? 15 | Write a short description detailing what you are doing, what you expect to happen, and what is currently happening. 16 | placeholder: | 17 | * Open the browser and call the endpoint `/`. 18 | * It returns a JSON with `{"Hello": "World"}`. 19 | * But I expected it to return `{"Hello": "Sara"}`. 20 | validations: 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: Example Code / Steps To Reproduce 25 | description: | 26 | Please add a self-contained, minimal, and reproducible example and or stack trace of the issue you are currently facing. Doing so 27 | increases the chances of the issue your a facing being quickly fixed. 28 | placeholder: | 29 | ```from pydantic import BaseModel 30 | from fastapi import FastAPI 31 | from fastapi_crudrouter import MemoryCRUDRouter as CRUDRouter 32 | 33 | class Potato(BaseModel): 34 | id: int 35 | color: str 36 | mass: float 37 | 38 | app = FastAPI() 39 | app.include_router(CRUDRouter(schema=Potato))``` 40 | validations: 41 | required: false 42 | - type: checkboxes 43 | id: operating-systems 44 | attributes: 45 | label: On which operating system are you facing this issue? 46 | description: You may select more than one. 47 | options: 48 | - label: Windows 49 | - label: Mac 50 | - label: Linux 51 | validations: 52 | required: true 53 | - type: input 54 | id: fastapi_crouter-version 55 | attributes: 56 | label: FastAPI-Crudrouter Version 57 | description: | 58 | What version of FastApi-Crudrouter are you using? 59 | 60 | You can find the FastApi-Crudrouter version with: 61 | ```bash 62 | python -c "import fastapi_crudrouter; print(fastapi_crudrouter.__version__)" 63 | ``` 64 | validations: 65 | required: true 66 | - type: input 67 | id: python-version 68 | attributes: 69 | label: Python Version 70 | description: | 71 | What Python version are you using? 72 | 73 | You can find the Python version with: 74 | ```bash 75 | python --version 76 | ``` 77 | validations: 78 | required: true 79 | - type: dropdown 80 | id: contributing 81 | attributes: 82 | label: | 83 | Would you be intrested in PR'ing / contributing to the fix for this issue. New contributors to fastapi-crudrouter are 84 | always welcome. See the [contributing docs](https://fastapi-crudrouter.awtkns.com/contributing) for more detials. 85 | options: 86 | - Yes 87 | - No 88 | - Unsure 89 | validations: 90 | required: false 91 | - type: textarea 92 | attributes: 93 | label: Anything else? 94 | description: | 95 | Links? References? Anything that will give us more context about the issue you are encountering! 96 | 97 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 98 | validations: 99 | required: false 100 | - type: markdown 101 | attributes: 102 | value: Thanks for taking the time to submit this issue. By doing so, you're improving the package for everyone. 🎉 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/scripts/releases.py: -------------------------------------------------------------------------------- 1 | import re 2 | from os import environ 3 | 4 | from github import Github 5 | from github.GitRelease import GitRelease 6 | 7 | GITHUB_REPOSITORY = environ.get("GITHUB_REPOSITORY", "awtkns/fastapi-crudrouter") 8 | GITHUB_TOKEN = environ.get("GH_TOKEN") or environ.get("GITHUB_TOKEN") 9 | GITHUB_URL = "https://github.com" 10 | GITHUB_BRANCH = "master" 11 | FILE_PATH = "docs/en/docs/releases.md" 12 | COMMIT_MESSAGE = "🤖 auto update releases.md" 13 | 14 | 15 | gh = Github(GITHUB_TOKEN) 16 | 17 | 18 | def generate_header(r: GitRelease, header_row: bool = False): 19 | header = "Release Notes\n===\n" if header_row else "\n\n---\n" 20 | 21 | return f"""{header} 22 | ## [{r.title}]({r.html_url}){" { .releases } "} 23 | {r.created_at.date()} 24 | """ 25 | 26 | 27 | def commit_update(content: str): 28 | file = repo.get_contents(FILE_PATH) 29 | old_content = file.decoded_content.decode() 30 | 31 | if new_content == old_content: 32 | print("No new release information, Skipping.") 33 | else: 34 | print("Uploading new release documentation") 35 | 36 | repo.update_file( 37 | file.path, 38 | message=COMMIT_MESSAGE, 39 | content=content, 40 | sha=file.sha, 41 | branch=GITHUB_BRANCH, 42 | ) 43 | 44 | 45 | def insert_links(content: str): 46 | """Replaces both #pull and @author with correct links""" 47 | pull_url = repo.html_url + "/pull" 48 | content = re.sub(r"#(\d+)", rf"[#\1]({pull_url}/\1)", content) 49 | 50 | compare_url = repo.html_url + "/compare" 51 | content = re.sub(rf"{compare_url}/([^\s]+)", rf"[`\1`]({compare_url}/\1)", content) 52 | 53 | return re.sub(r"@(\S+)", rf"[@\1]({GITHUB_URL}/\1)", content) 54 | 55 | 56 | if __name__ == "__main__": 57 | repo = gh.get_repo(GITHUB_REPOSITORY) 58 | 59 | new_content = "" 60 | show_header = True 61 | for release in repo.get_releases(): 62 | if not release.draft: 63 | new_content += generate_header(release, header_row=show_header) 64 | new_content += release.body 65 | show_header = False 66 | 67 | new_content = insert_links(new_content) 68 | commit_update(new_content) 69 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v2 16 | - name: Run Black Code Formatter 17 | uses: psf/black@stable 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install -r tests/dev.requirements.txt 22 | - name: Check Typing with mypy 23 | run: | 24 | mypy fastapi_crudrouter 25 | - name: Lint with flake8 26 | run: | 27 | flake8 fastapi_crudrouter 28 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | deploy: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: '3.10' 22 | - name: Bump Version 23 | env: 24 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 25 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 26 | if: github.event.pusher.name == 'awtkns' 27 | run: | 28 | curl -s https://pypi.org/pypi/fastapi-crudrouter/json | grep -Eo '"version":"[0-9].[0-9][0-9]?.[0-9][0-9]?"' | grep -Eo "[0-9].[0-9][0-9]?.[0-9][0-9]?" > old 29 | grep -Eo '__version__ = "[0-9].[0-9][0-9]?.[0-9][0-9]?"' ./fastapi_crudrouter/_version.py | grep -Eo "[0-9].[0-9][0-9]?.[0-9][0-9]?" > new 30 | 31 | cat new 32 | cat old 33 | if cmp --silent new old; then 34 | echo --- SKIPPING VERSION BUMP --- 35 | else 36 | echo ---BUMPING VERSION--- 37 | 38 | python -m pip install --upgrade pip 39 | pip install setuptools wheel twine 40 | 41 | python setup.py sdist bdist_wheel 42 | twine upload dist/* 43 | fi 44 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Python application 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ] 15 | services: 16 | postgres: 17 | image: postgres 18 | env: 19 | POSTGRES_USER: postgres 20 | POSTGRES_PASSWORD: postgres 21 | POSTGRES_HOST: postgres 22 | ports: 23 | - 5432/tcp 24 | options: >- 25 | --health-cmd pg_isready 26 | --health-interval 10s 27 | --health-timeout 5s 28 | --health-retries 5 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v2 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install -r tests/dev.requirements.txt 40 | - name: Test with pytest 41 | env: 42 | POSTGRES_DB: test 43 | POSTGRES_USER: postgres 44 | POSTGRES_PASSWORD: postgres 45 | POSTGRES_HOST: localhost 46 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} 47 | run: | 48 | pytest 49 | -------------------------------------------------------------------------------- /.github/workflows/update-release-docs.yml: -------------------------------------------------------------------------------- 1 | name: Update Release Docs 2 | on: release 3 | 4 | jobs: 5 | Update: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Python 11 | uses: actions/setup-python@v2 12 | - name: Install dependencies 13 | run: | 14 | python -m pip install --upgrade pip 15 | pip install pygithub 16 | - name: Updating Releases Documentation 17 | env: 18 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | run: | 21 | python .github/scripts/releases.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | .vscode 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .nox/ 33 | .coverage 34 | .coverage.* 35 | .cache 36 | nosetests.xml 37 | coverage.xml 38 | *.cover 39 | *.py,cover 40 | .hypothesis/ 41 | .pytest_cache/ 42 | .mypy_cache/ 43 | 44 | # Environments 45 | .env 46 | .venv 47 | env/ 48 | venv/ 49 | ENV/ 50 | env.bak/ 51 | venv.bak/ 52 | p38venv/ 53 | 54 | # Databases 55 | *.db 56 | db.sqlite3* 57 | 58 | # IDEs 59 | .idea 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Pull requests and contributions are welcome. Please read the [contributions guidelines](https://fastapi-crudrouter.awtkns.com/contributing) for more details. 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adam Watkins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <p align="center"> 2 | <img src="https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/master/docs/en/docs/assets/logo.png" height="200" /> 3 | </p> 4 | <p align="center"> 5 | <em>⚡ Create CRUD routes with lighting speed</em> ⚡</br> 6 | <sub>A dynamic FastAPI router that automatically creates CRUD routes for your models</sub> 7 | </p> 8 | <p align="center"> 9 | <img alt="Tests" src="https://img.shields.io/github/actions/workflow/status/awtkns/fastapi-crudrouter/.github/workflows/pytest.yml?color=%2334D058" /> 10 | <img alt="Downloads" src="https://img.shields.io/pypi/dm/fastapi-crudrouter?color=%2334D058" /> 11 | <a href="https://pypi.org/project/fastapi-crudrouter" target="_blank"> 12 | <img src="https://img.shields.io/pypi/v/fastapi-crudrouter?color=%2334D058&label=pypi%20package" alt="Package version"> 13 | </a> 14 | <img alt="License" src="https://img.shields.io/github/license/awtkns/fastapi-crudrouter?color=%2334D058" /> 15 | </p> 16 | <p align="center"> 17 | <img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/fastapi-crudrouter"> 18 | </p> 19 | 20 | --- 21 | 22 | **Documentation**: <a href="https://fastapi-crudrouter.awtkns.com" target="_blank">https://fastapi-crudrouter.awtkns.com</a> 23 | 24 | **Source Code**: <a href="https://github.com/awtkns/fastapi-crudrouter" target="_blank">https://github.com/awtkns/fastapi-crudrouter</a> 25 | 26 | --- 27 | Tired of rewriting generic CRUD routes? Need to rapidly prototype a feature for a presentation 28 | or a hackathon? Thankfully, [fastapi-crudrouter](https://github.com/awtkns/fastapi-crudrouter) has your back. As an 29 | extension to the APIRouter included with [FastAPI](https://fastapi.tiangolo.com/), the FastAPI CRUDRouter will automatically 30 | generate and document your CRUD routes for you, all you have to do is pass your model and maybe your database connection. 31 | 32 | FastAPI-CRUDRouter is **lighting fast**, well tested, and **production ready**. 33 | 34 | 35 | ## Installation 36 | ```bash 37 | pip install fastapi-crudrouter 38 | ``` 39 | 40 | ## Basic Usage 41 | Below is a simple example of what the CRUDRouter can do. In just ten lines of code, you can generate all 42 | the crud routes you need for any model. A full list of the routes generated can be found [here](https://fastapi-crudrouter.awtkns.com/routing). 43 | 44 | ```python 45 | from pydantic import BaseModel 46 | from fastapi import FastAPI 47 | from fastapi_crudrouter import MemoryCRUDRouter as CRUDRouter 48 | 49 | class Potato(BaseModel): 50 | id: int 51 | color: str 52 | mass: float 53 | 54 | app = FastAPI() 55 | app.include_router(CRUDRouter(schema=Potato)) 56 | ``` 57 | 58 | ## Advanced Usage 59 | fastapi-crudrouter provides a number of features that allow you to get the most out of your automatically generated CRUD 60 | routes. Listed below are some highlights. 61 | 62 | - Automatic Pagination ([docs](https://fastapi-crudrouter.awtkns.com/pagination/)) 63 | - Ability to Provide Custom Create and Update Schemas ([docs](https://fastapi-crudrouter.awtkns.com/schemas/)) 64 | - Dynamic Generation of Create and Update Schemas ([docs](https://fastapi-crudrouter.awtkns.com/schemas/)) 65 | - Ability to Add, Customize, or Disable Specific Routes ([docs](https://fastapi-crudrouter.awtkns.com/routing/)) 66 | - Native Support for FastAPI Dependency Injection ([docs](https://fastapi-crudrouter.awtkns.com/dependencies/)) 67 | 68 | ## Supported Backends / ORMs 69 | fastapi-crudrouter currently supports a number of backends / ORMs. Listed below are the backends currently supported. This list will 70 | likely grow in future releases. 71 | 72 | - In Memory ([docs](https://fastapi-crudrouter.awtkns.com/backends/memory/)) 73 | - SQLAlchemy ([docs](https://fastapi-crudrouter.awtkns.com/backends/sqlalchemy/)) 74 | - Databases (async) ([docs](https://fastapi-crudrouter.awtkns.com/backends/async/)) 75 | - Gino (async) ([docs](https://fastapi-crudrouter.awtkns.com/backends/gino.html)) 76 | - Ormar (async) ([docs](https://fastapi-crudrouter.awtkns.com/backends/ormar/)) 77 | - Tortoise ORM (async) ([docs](https://fastapi-crudrouter.awtkns.com/backends/tortoise/)) 78 | 79 | ## OpenAPI Support 80 | By default, all routes generated by the CRUDRouter will be documented according to OpenAPI spec. 81 | 82 | Below are the default routes created by the CRUDRouter shown in the generated OpenAPI documentation. 83 | 84 | ![OpenAPI Route Overview](https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/master/docs/en/docs/assets/RouteOverview.png) 85 | 86 | The CRUDRouter is able to dynamically generate detailed documentation based on the models given to it. 87 | 88 | ![OpenAPI Route Detail](https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/master/docs/en/docs/assets/RouteDetail.png) 89 | -------------------------------------------------------------------------------- /docs/en/docs/assets/RouteDetail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/9b829865d85113a3f16f94c029502a9a584d47bb/docs/en/docs/assets/RouteDetail.png -------------------------------------------------------------------------------- /docs/en/docs/assets/RouteOverview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/9b829865d85113a3f16f94c029502a9a584d47bb/docs/en/docs/assets/RouteOverview.png -------------------------------------------------------------------------------- /docs/en/docs/assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/9b829865d85113a3f16f94c029502a9a584d47bb/docs/en/docs/assets/banner.png -------------------------------------------------------------------------------- /docs/en/docs/assets/bolt-grad.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 | <svg 3 | xmlns:dc="http://purl.org/dc/elements/1.1/" 4 | xmlns:cc="http://creativecommons.org/ns#" 5 | xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 6 | xmlns:svg="http://www.w3.org/2000/svg" 7 | xmlns="http://www.w3.org/2000/svg" 8 | id="svg8" 9 | version="1.1" 10 | viewBox="0 0 6.3499999 6.3499999" 11 | height="6.3499999mm" 12 | width="6.3499999mm"> 13 | <defs> 14 | <linearGradient id="grad" gradientTransform="rotate(-30) translate(-0.25, -.25)"> 15 | <stop offset="0%" stop-color="#3d3393"/> 16 | <stop offset="37%" stop-color="#2b76b9"/> 17 | <stop offset="65%" stop-color="#2cacd1"/> 18 | <stop offset="100%" stop-color="#35eb93"/> 19 | </linearGradient> 20 | </defs> 21 | <metadata 22 | id="metadata5"> 23 | <rdf:RDF> 24 | <cc:Work 25 | rdf:about=""> 26 | <dc:format>image/svg+xml</dc:format> 27 | <dc:type 28 | rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> 29 | <dc:title></dc:title> 30 | </cc:Work> 31 | </rdf:RDF> 32 | </metadata> 33 | <g 34 | transform="translate(-87.539286,-84.426191)" 35 | id="layer1"> 36 | <path 37 | id="path815" 38 | d="m 87.539286,84.426191 h 6.35 v 6.35 h -6.35 z" 39 | style="fill:none;stroke-width:0.26458332" /> 40 | <path 41 | style="stroke-width:0.26458332;" 42 | id="path817" 43 | fill="url(#grad)" 44 | d="m 90.714286,84.960649 c -1.457854,0 -2.640542,1.182688 -2.640542,2.640542 0,1.457854 1.182688,2.640542 2.640542,2.640542 1.457854,0 2.640542,-1.182688 2.640542,-2.640542 0,-1.457854 -1.182688,-2.640542 -2.640542,-2.640542 z m -0.137583,4.757209 v -1.656292 h -0.92075 l 1.322916,-2.577042 v 1.656292 h 0.886354 z" /> 45 | </g> 46 | </svg> 47 | -------------------------------------------------------------------------------- /docs/en/docs/assets/bolt.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 | <svg 3 | xmlns:dc="http://purl.org/dc/elements/1.1/" 4 | xmlns:cc="http://creativecommons.org/ns#" 5 | xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 6 | xmlns:svg="http://www.w3.org/2000/svg" 7 | xmlns="http://www.w3.org/2000/svg" 8 | id="svg8" 9 | version="1.1" 10 | viewBox="0 0 6.3499999 6.3499999" 11 | height="6.3499999mm" 12 | width="6.3499999mm"> 13 | <defs 14 | id="defs2" /> 15 | <metadata 16 | id="metadata5"> 17 | <rdf:RDF> 18 | <cc:Work 19 | rdf:about=""> 20 | <dc:format>image/svg+xml</dc:format> 21 | <dc:type 22 | rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> 23 | <dc:title></dc:title> 24 | </cc:Work> 25 | </rdf:RDF> 26 | </metadata> 27 | <g 28 | transform="translate(-87.539286,-84.426191)" 29 | id="layer1"> 30 | <path 31 | id="path815" 32 | d="m 87.539286,84.426191 h 6.35 v 6.35 h -6.35 z" 33 | style="fill:none;stroke-width:0.26458332" /> 34 | <path 35 | style="stroke-width:0.26458332;fill:#ffffff" 36 | id="path817" 37 | d="m 90.714286,84.960649 c -1.457854,0 -2.640542,1.182688 -2.640542,2.640542 0,1.457854 1.182688,2.640542 2.640542,2.640542 1.457854,0 2.640542,-1.182688 2.640542,-2.640542 0,-1.457854 -1.182688,-2.640542 -2.640542,-2.640542 z m -0.137583,4.757209 v -1.656292 h -0.92075 l 1.322916,-2.577042 v 1.656292 h 0.886354 z" /> 38 | </g> 39 | </svg> 40 | -------------------------------------------------------------------------------- /docs/en/docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/9b829865d85113a3f16f94c029502a9a584d47bb/docs/en/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/en/docs/backends/async.md: -------------------------------------------------------------------------------- 1 | Asynchronous routes will be automatically generated when using the `DatabasesCRUDRouter`. To use it, you must pass a 2 | [pydantic](https://pydantic-docs.helpmanual.io/) model, your SQLAlchemy Table, and the databases database. 3 | This CRUDRouter is intended to be used with the python [Databases](https://www.encode.io/databases/) library. An example 4 | of how to use [Databases](https://www.encode.io/databases/) with FastAPI can be found both 5 | [here](https://fastapi.tiangolo.com/advanced/async-sql-databases/) and below. 6 | 7 | !!! warning 8 | To use the `DatabasesCRUDRouter`, Databases **and** SQLAlchemy must be first installed. 9 | 10 | ## Minimal Example 11 | Below is a minimal example assuming that you have already imported and created 12 | all the required models and database connections. 13 | 14 | ```python 15 | from fastapi_crudrouter import DatabasesCRUDRouter 16 | from fastapi import FastAPI 17 | 18 | app = FastAPI() 19 | 20 | router = DatabasesCRUDRouter( 21 | schema=MyPydanticModel, 22 | table=my_table, 23 | database=my_database 24 | ) 25 | app.include_router(router) 26 | ``` 27 | 28 | ## Full Example 29 | 30 | ```python 31 | import databases 32 | import sqlalchemy 33 | 34 | from pydantic import BaseModel 35 | from fastapi import FastAPI 36 | from fastapi_crudrouter import DatabasesCRUDRouter 37 | 38 | DATABASE_URL = "sqlite:///./test.db" 39 | 40 | database = databases.Database(DATABASE_URL) 41 | engine = sqlalchemy.create_engine( 42 | DATABASE_URL, 43 | connect_args={"check_same_thread": False} 44 | ) 45 | 46 | metadata = sqlalchemy.MetaData() 47 | potatoes = sqlalchemy.Table( 48 | "potatoes", 49 | metadata, 50 | sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), 51 | sqlalchemy.Column("thickness", sqlalchemy.Float), 52 | sqlalchemy.Column("mass", sqlalchemy.Float), 53 | sqlalchemy.Column("color", sqlalchemy.String), 54 | sqlalchemy.Column("type", sqlalchemy.String), 55 | ) 56 | metadata.create_all(bind=engine) 57 | 58 | 59 | class PotatoCreate(BaseModel): 60 | thickness: float 61 | mass: float 62 | color: str 63 | type: str 64 | 65 | 66 | class Potato(PotatoCreate): 67 | id: int 68 | 69 | 70 | app = FastAPI() 71 | 72 | 73 | @app.on_event("startup") 74 | async def startup(): 75 | await database.connect() 76 | 77 | 78 | @app.on_event("shutdown") 79 | async def shutdown(): 80 | await database.disconnect() 81 | 82 | 83 | router = DatabasesCRUDRouter( 84 | schema=Potato, 85 | create_schema=PotatoCreate, 86 | table=potatoes, 87 | database=database 88 | ) 89 | app.include_router(router) 90 | ``` -------------------------------------------------------------------------------- /docs/en/docs/backends/gino.md: -------------------------------------------------------------------------------- 1 | Asynchronous routes will be automatically generated when using the `GinoCRUDRouter`. To use it, you must pass a 2 | [pydantic](https://pydantic-docs.helpmanual.io/) model, your SQLAlchemy Table, and the databases database. 3 | This CRUDRouter is intended to be used with the python [Gino](https://python-gino.org/) library. An example 4 | of how to use [Gino](https://python-gino.org/) with FastAPI can be found both 5 | [here](https://python-gino.org/docs/en/1.0/tutorials/fastapi.html) and below. 6 | 7 | !!! warning 8 | To use the `GinoCRUDRouter`, Gino **and** SQLAlchemy must be first installed. 9 | 10 | ## Minimal Example 11 | Below is a minimal example assuming that you have already imported and created 12 | all the required models and database connections. 13 | 14 | ```python 15 | router = GinoCRUDRouter( 16 | schema=MyPydanticModel, 17 | db=db, 18 | db_model=MyModel 19 | ) 20 | app.include_router(router) 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/en/docs/backends/memory.md: -------------------------------------------------------------------------------- 1 | The `MemoryCRUDRouter` is the simplest usage of the CRUDRouters. To use it, simply pass a 2 | [pydantic](https://pydantic-docs.helpmanual.io/) model to it. As a database is not required, the `MemoryCRUDRouter` is 3 | well suited for rapid bootstrapping and prototyping. 4 | 5 | ## Usage 6 | ```python 7 | from pydantic import BaseModel 8 | from fastapi import FastAPI 9 | from fastapi_crudrouter import MemoryCRUDRouter 10 | 11 | class Potato(BaseModel): 12 | id: int 13 | color: str 14 | mass: float 15 | 16 | app = FastAPI() 17 | router = MemoryCRUDRouter(schema=Potato) 18 | app.include_router(router) 19 | ``` 20 | 21 | !!! warning 22 | When using the `MemoryCRUDRouter`, the schema (model) passed to it must have the `id: int` property. 23 | 24 | !!! danger 25 | The storage for the `MemoryCRUDRouter` resides in memory, not a database. Hence, the data is not persistent. Be careful when using it beyond 26 | the rapid bootstrapping or prototyping phase. -------------------------------------------------------------------------------- /docs/en/docs/backends/ormar.md: -------------------------------------------------------------------------------- 1 | When generating routes, the `OrmarCRUDRouter` will automatically tie into your database 2 | using your [ormar](https://collerek.github.io/ormar/) models. To use it, simply pass your ormar database model as the schema. 3 | 4 | ## Simple Example 5 | 6 | Below is an example assuming that you have already imported and created all the required 7 | models. 8 | 9 | ```python 10 | from fastapi_crudrouter import OrmarCRUDRouter 11 | from fastapi import FastAPI 12 | 13 | app = FastAPI() 14 | 15 | router = OrmarCRUDRouter( 16 | schema=MyOrmarModel, 17 | create_schema=Optional[MyPydanticCreateModel], 18 | update_schema=Optional[MyPydanticUpdateModel] 19 | ) 20 | 21 | app.include_router(router) 22 | ``` 23 | 24 | !!! note 25 | The `create_schema` should not include the *primary id* field as this be 26 | generated by the database. 27 | 28 | ## Full Example 29 | 30 | ```python 31 | # example.py 32 | import databases 33 | import ormar 34 | import sqlalchemy 35 | import uvicorn 36 | from fastapi import FastAPI 37 | 38 | from fastapi_crudrouter import OrmarCRUDRouter 39 | 40 | DATABASE_URL = "sqlite:///./test.db" 41 | database = databases.Database(DATABASE_URL) 42 | metadata = sqlalchemy.MetaData() 43 | 44 | app = FastAPI() 45 | 46 | 47 | @app.on_event("startup") 48 | async def startup(): 49 | await database.connect() 50 | 51 | 52 | @app.on_event("shutdown") 53 | async def shutdown(): 54 | await database.disconnect() 55 | 56 | 57 | class BaseMeta(ormar.ModelMeta): 58 | metadata = metadata 59 | database = database 60 | 61 | 62 | def _setup_database(): 63 | # if you do not have the database run this once 64 | engine = sqlalchemy.create_engine(DATABASE_URL) 65 | metadata.drop_all(engine) 66 | metadata.create_all(engine) 67 | return engine, database 68 | 69 | 70 | class Potato(ormar.Model): 71 | class Meta(BaseMeta): 72 | pass 73 | 74 | id = ormar.Integer(primary_key=True) 75 | thickness = ormar.Float() 76 | mass = ormar.Float() 77 | color = ormar.String(max_length=255) 78 | type = ormar.String(max_length=255) 79 | 80 | 81 | app.include_router( 82 | OrmarCRUDRouter( 83 | schema=Potato, 84 | prefix="potato", 85 | ) 86 | ) 87 | 88 | if __name__ == "__main__": 89 | uvicorn.run("example:app", host="127.0.0.1", port=5000, log_level="info") 90 | ``` 91 | -------------------------------------------------------------------------------- /docs/en/docs/backends/sqlalchemy.md: -------------------------------------------------------------------------------- 1 | When generating routes, the `SQLAlchemyCRUDRouter` will automatically tie into 2 | your database using your [SQLAlchemy](https://www.sqlalchemy.org/) models. To use it, you must pass a 3 | [pydantic](https://pydantic-docs.helpmanual.io/) model, your SQLAlchemy model to it, and the 4 | database dependency. 5 | 6 | !!! warning 7 | To use the `SQLAlchemyCRUDRouter`, SQLAlchemy must be first installed. 8 | 9 | ## Simple Example 10 | Below is an example assuming that you have already imported and created all the required models. 11 | 12 | ```python 13 | from fastapi_crudrouter import SQLAlchemyCRUDRouter 14 | from fastapi import FastAPI 15 | 16 | app = FastAPI() 17 | 18 | router = SQLAlchemyCRUDRouter( 19 | schema=MyPydanticModel, 20 | create_schema=MyPydanticCreateModel, 21 | db_model=MyDBModel, 22 | db=get_db 23 | ) 24 | 25 | app.include_router(router) 26 | ``` 27 | 28 | !!! note 29 | The `create_schema` should not include the *primary id* field as this be generated by the database. 30 | 31 | ## Full Example 32 | 33 | ```python 34 | from sqlalchemy import Column, String, Float, Integer 35 | from sqlalchemy import create_engine 36 | from sqlalchemy.ext.declarative import declarative_base 37 | from sqlalchemy.orm import sessionmaker 38 | 39 | from pydantic import BaseModel 40 | from fastapi import FastAPI 41 | from fastapi_crudrouter import SQLAlchemyCRUDRouter 42 | 43 | app = FastAPI() 44 | engine = create_engine( 45 | "sqlite:///./app.db", 46 | connect_args={"check_same_thread": False} 47 | ) 48 | 49 | SessionLocal = sessionmaker( 50 | autocommit=False, 51 | autoflush=False, 52 | bind=engine 53 | ) 54 | 55 | Base = declarative_base() 56 | 57 | 58 | def get_db(): 59 | session = SessionLocal() 60 | try: 61 | yield session 62 | session.commit() 63 | finally: 64 | session.close() 65 | 66 | 67 | class PotatoCreate(BaseModel): 68 | thickness: float 69 | mass: float 70 | color: str 71 | type: str 72 | 73 | 74 | class Potato(PotatoCreate): 75 | id: int 76 | 77 | class Config: 78 | orm_mode = True 79 | 80 | 81 | class PotatoModel(Base): 82 | __tablename__ = 'potatoes' 83 | id = Column(Integer, primary_key=True, index=True) 84 | thickness = Column(Float) 85 | mass = Column(Float) 86 | color = Column(String) 87 | type = Column(String) 88 | 89 | 90 | Base.metadata.create_all(bind=engine) 91 | 92 | router = SQLAlchemyCRUDRouter( 93 | schema=Potato, 94 | create_schema=PotatoCreate, 95 | db_model=PotatoModel, 96 | db=get_db, 97 | prefix='potato' 98 | ) 99 | 100 | app.include_router(router) 101 | ``` -------------------------------------------------------------------------------- /docs/en/docs/backends/tortoise.md: -------------------------------------------------------------------------------- 1 | When generating routes, the `TortoiseCRUDRouter` will automatically tie into 2 | your database using your [Tortoise](https://tortoise-orm.readthedocs.io/en/latest/index.html) models. To use it, you must pass a 3 | [pydantic](https://pydantic-docs.helpmanual.io/) schema, your Tortoise database model to it, and register Tortoise ORM with your FastAPI App. 4 | 5 | !!! warning 6 | To use the `TortoiseCRUDRouter`, [Tortoise ORM](https://pypi.org/project/tortoise-orm/) must be first installed. 7 | 8 | !!! warning 9 | Tortoise ORM works on python versions 3.7 and higher, so if you want to use this backend, you would not be able to use `python 3.6`. 10 | 11 | ## Simple Example 12 | Below is an example assuming that you have already imported and created all the required models. 13 | 14 | ```python 15 | from fastapi_crudrouter.core.tortoise import TortoiseCRUDRouter 16 | from fastapi import FastAPI 17 | 18 | app = FastAPI() 19 | register_tortoise(app, config=TORTOISE_ORM) 20 | 21 | router = TortoiseCRUDRouter( 22 | schema=MyPydanticModel, 23 | db_model=MyDBModel, 24 | prefix="test" 25 | ) 26 | 27 | app.include_router(router) 28 | ``` 29 | 30 | You can provide your TORTOISE_ORM from a file or as a dictionary. If you want to provide it as a dictionary, this would be how: 31 | 32 | ```python 33 | TORTOISE_ORM = { 34 | "connections": {"default": 'postgres_url_here'}, 35 | "apps": { 36 | "models": { 37 | "models": ["example"], 38 | "default_connection": "default", 39 | }, 40 | }, 41 | } 42 | ``` 43 | 44 | Where `"models": ["example"]` represents that `example.py` is where your Tortoise database ORM models are located. 45 | If you ended up having a lot of these, you might want to break it out into a `models.py` file and thus your config would change to `"models": ["models"]`. 46 | If you use [Aerich](https://github.com/tortoise/aerich) for database migrations, you'll need to add `"aerich.models"` to your config. 47 | 48 | !!! note 49 | The `create_schema` should not include the *primary id* field as this should be generated by the database. If you don't provide a create_schema, a primary key stripped schema will be made for you. 50 | 51 | ## Full Example 52 | 53 | ```python 54 | # example.py 55 | 56 | import uvicorn as uvicorn 57 | from fastapi import FastAPI 58 | from fastapi_crudrouter.core.tortoise import TortoiseCRUDRouter 59 | from tortoise.contrib.fastapi import register_tortoise 60 | from tortoise.contrib.pydantic import pydantic_model_creator 61 | from tortoise.models import Model 62 | from tortoise import fields, Tortoise 63 | 64 | TORTOISE_ORM = { 65 | "connections": {"default": 'postgres_url'}, 66 | "apps": { 67 | "models": { 68 | "models": ["example"], 69 | "default_connection": "default", 70 | }, 71 | }, 72 | } 73 | 74 | # Create Database Tables 75 | async def init(): 76 | await Tortoise.init(config=TORTOISE_ORM) 77 | await Tortoise.generate_schemas() 78 | 79 | app = FastAPI() 80 | register_tortoise(app, config=TORTOISE_ORM) 81 | 82 | 83 | # Tortoise ORM Model 84 | class TestModel(Model): 85 | test = fields.IntField(null=False, description=f"Test value") 86 | ts = fields.IntField(null=False, description=f"Epoch time") 87 | 88 | 89 | # Pydantic schema 90 | TestSchema = pydantic_model_creator(TestModel, name=f"{TestModel.__name__}Schema") 91 | TestSchemaCreate = pydantic_model_creator(TestModel, name=f"{TestModel.__name__}SchemaCreate", exclude_readonly=True) 92 | 93 | # Make your FastAPI Router from your Pydantic schema and Tortoise Model 94 | router = TortoiseCRUDRouter( 95 | schema=TestSchema, 96 | create_schema=TestSchemaCreate, 97 | db_model=TestModel, 98 | prefix="test" 99 | ) 100 | 101 | # Add it to your app 102 | app.include_router(router) 103 | 104 | if __name__ == "__main__": 105 | uvicorn.run("example:app", host="127.0.0.1", port=5000, log_level="info") 106 | ``` -------------------------------------------------------------------------------- /docs/en/docs/contributing.md: -------------------------------------------------------------------------------- 1 | As an open source package, fastapi-crudrouter accepts contributions from **all members** of the community. If you are interested in the 2 | contributing, reading the development guidelines below may help you in the process. 😊 3 | 4 | ## Github 5 | 6 | #### Issues 7 | Please create an issue to report a bug, request a feature or to simply ask a question. 8 | 9 | 10 | #### Pull Requests 11 | Unless the pull request is a simple bugfix, please try to create an issue before starting on the implementation of your pull request. 12 | This ensures that the potential feature is in alignment with CRUDRouter's goals moving forward. This also allows for feedback 13 | on the feature and potential help on where to start implementation wise. 14 | 15 | ## Development 16 | 17 | ### Installing the Dev Requirements 18 | FastAPI-Crudrouter requires as set of development requirements that can installed with `pip` be found in `tests/dev.requirements.txt` 19 | 20 | <div class="termy"> 21 | 22 | ```console 23 | $ pip install -r tests/dev.requirements.txt 24 | ---> 100% 25 | ``` 26 | 27 | </div> 28 | 29 | ### Testing 30 | When adding additional features, please try to add additional tests that prove that your implementation 31 | works and is bug free. 32 | 33 | #### Test requirements 34 | Tests require a postgres database for tests to run. The easiest way to accomplish this is with docker. This project offers 35 | a docker-compose file at tests/conf/dev.docker-compose.yml. You can use this file with 36 | 37 | ```bash 38 | docker compose -f tests/conf/dev.docker-compose.yml up -d 39 | ``` 40 | 41 | After testing you can tear down the running containers with 42 | 43 | ```bash 44 | docker compose -f tests/conf/dev.docker-compose.yml down 45 | ``` 46 | 47 | #### Running tests 48 | Crudrouter utilizes the [pytest](https://docs.pytest.org/en/latest/) framework for all of its unittests. Tests can be run 49 | as shown below. 50 | 51 | <div class="termy"> 52 | 53 | ```console 54 | $ pytest 55 | ---> 100% 56 | ``` 57 | 58 | </div> 59 | 60 | ### Linting, Formatting and Typing 61 | 62 | With `dev.requirements.txt` installed above you also install tools to lint, format and static type check the project. 63 | 64 | To format the project run: 65 | 66 | ```bash 67 | black fastapi_crudrouter tests 68 | ``` 69 | 70 | To check styles, imports, annotations, pep8 etc. run: 71 | 72 | ```bash 73 | flake8 fastapi_crudrouter 74 | ``` 75 | 76 | To check static types annotations run: 77 | 78 | ```bash 79 | mypy fastapi_crudrouter tests 80 | ``` 81 | 82 | ### Documentation 83 | Crudrouter's documentation was built using [mkdocs-material](https://squidfunk.github.io/mkdocs-material/). To start the development 84 | documentation server, please first install mkdocs-material and then run the server as shown below. 85 | 86 | <div class="termy"> 87 | 88 | ```console 89 | $ pip install mkdocs-material 90 | ---> 100% 91 | $ cd docs/en 92 | $ mkdocs serve 93 | ``` 94 | 95 | </div> 96 | -------------------------------------------------------------------------------- /docs/en/docs/css/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /*--md-primary-fg-color: #3d3393;*/ 3 | /*--md-primary-fg-color--light: #3d3393;*/ 4 | /*--md-primary-fg-color--dark: #3d3393;*/ 5 | } 6 | 7 | header { 8 | /* https://webgradients.com/ - 142 Space Shift */ 9 | background-image: linear-gradient(60deg, #3d3393 0%, #2b76b9 37%, #2cacd1 65%, #35eb93 100%); 10 | } 11 | 12 | /* Sidebar */ 13 | @media screen and (max-width: 76.1875em) { 14 | .md-nav__title { 15 | background-image: linear-gradient(60deg, #3d3393 0%, #2b76b9 37%, #2cacd1 65%, #35eb93 100%); 16 | } 17 | 18 | .md-nav--primary .md-nav__title { 19 | color: white; 20 | } 21 | } 22 | 23 | @media screen and (min-width: 60em) { 24 | .md-nav__title[for="__toc"] { 25 | background-image: None !important; 26 | } 27 | } 28 | 29 | a.announce:link, a.announce:visited { 30 | color: #fff; 31 | } 32 | 33 | a.announce:hover { 34 | color: var(--md-accent-fg-color); 35 | } 36 | 37 | h2.releases { 38 | margin-bottom: 0; 39 | } 40 | 41 | h2.releases + p { 42 | margin-top: 0; 43 | } 44 | 45 | -------------------------------------------------------------------------------- /docs/en/docs/css/termynal.css: -------------------------------------------------------------------------------- 1 | /** 2 | * termynal.js 3 | * 4 | * @author Ines Montani <ines@ines.io> 5 | * @version 0.0.1 6 | * @license MIT 7 | */ 8 | 9 | :root { 10 | --color-bg: #252a33; 11 | --color-text: #eee; 12 | --color-text-subtle: #a2a2a2; 13 | } 14 | 15 | [data-termynal] { 16 | width: 750px; 17 | max-width: 100%; 18 | background: var(--color-bg); 19 | color: var(--color-text); 20 | font-size: 18px; 21 | /* font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; */ 22 | font-family: 'Roboto Mono', 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; 23 | border-radius: 4px; 24 | padding: 75px 45px 35px; 25 | position: relative; 26 | -webkit-box-sizing: border-box; 27 | box-sizing: border-box; 28 | } 29 | 30 | [data-termynal]:before { 31 | content: ''; 32 | position: absolute; 33 | top: 15px; 34 | left: 15px; 35 | display: inline-block; 36 | width: 15px; 37 | height: 15px; 38 | border-radius: 50%; 39 | /* A little hack to display the window buttons in one pseudo element. */ 40 | background: #d9515d; 41 | -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; 42 | box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; 43 | } 44 | 45 | [data-termynal]:after { 46 | content: 'bash'; 47 | position: absolute; 48 | color: var(--color-text-subtle); 49 | top: 5px; 50 | left: 0; 51 | width: 100%; 52 | text-align: center; 53 | } 54 | 55 | a[data-terminal-control] { 56 | text-align: right; 57 | display: block; 58 | color: #aebbff; 59 | } 60 | 61 | [data-ty] { 62 | display: block; 63 | line-height: 2; 64 | } 65 | 66 | [data-ty]:before { 67 | /* Set up defaults and ensure empty lines are displayed. */ 68 | content: ''; 69 | display: inline-block; 70 | vertical-align: middle; 71 | } 72 | 73 | [data-ty="input"]:before, 74 | [data-ty-prompt]:before { 75 | margin-right: 0.75em; 76 | color: var(--color-text-subtle); 77 | } 78 | 79 | [data-ty="input"]:before { 80 | content: '$'; 81 | } 82 | 83 | [data-ty][data-ty-prompt]:before { 84 | content: attr(data-ty-prompt); 85 | } 86 | 87 | [data-ty-cursor]:after { 88 | content: attr(data-ty-cursor); 89 | font-family: monospace; 90 | margin-left: 0.5em; 91 | -webkit-animation: blink 1s infinite; 92 | animation: blink 1s infinite; 93 | } 94 | 95 | 96 | /* Cursor animation */ 97 | @-webkit-keyframes blink { 98 | 50% { 99 | opacity: 0; 100 | } 101 | } 102 | 103 | @keyframes blink { 104 | 50% { 105 | opacity: 0; 106 | } 107 | } -------------------------------------------------------------------------------- /docs/en/docs/dependencies.md: -------------------------------------------------------------------------------- 1 | All the CRUDRouters included with `fastapi_crudrouter` support FastAPI dependency injection. 2 | 3 | !!! tip 4 | Since all CRUDRouter's subclass the [FastAPI APIRouter](https://fastapi.tiangolo.com/tutorial/bigger-applications/?h=+router#apirouter), 5 | you can use any features APIRouter features. 6 | 7 | Below is a simple example of how you could use OAuth2 in conjunction with a CRUDRouter to secure your routes. 8 | 9 | ```python 10 | from fastapi import FastAPI, Depends, HTTPException 11 | from fastapi.security import OAuth2PasswordBearer 12 | from fastapi_crudrouter import MemoryCRUDRouter 13 | 14 | app = FastAPI() 15 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") 16 | 17 | def token_auth(token: str=Depends(oauth2_scheme)): 18 | if not token: 19 | raise HTTPException(401, "Invalid token") 20 | 21 | router = MemoryCRUDRouter(schema=MySchema, dependencies=[Depends(token_auth)]) 22 | app.include_router(router) 23 | ``` 24 | 25 | ## Custom Dependencies Per Route 26 | All CRUDRouters allow you to add a sequence of dependencies on a per-route basis. The dependencies can be set when 27 | initializing any CRUDRouter using the key word arguments below. 28 | 29 | ```python 30 | CRUDRouter( 31 | # ... 32 | get_all_route=[Depends(get_all_route_dep), ...], 33 | get_one_route=[Depends(get_one_route_dep), ...], 34 | create_route=[Depends(create_route_dep), ...], 35 | update_route=[Depends(update_route_dep), ...], 36 | delete_one_route=[Depends(user), ...], 37 | delete_all_route=[Depends(user), ...], 38 | ) 39 | 40 | ``` 41 | 42 | !!! tip "Multiple Dependencies Per Route" 43 | As they are passed as a sequence, you are able to set multiple dependencies individually per route. 44 | 45 | !!! attention "Disabling Routes Entirely" 46 | Setting the key word arguments shown above to `False`, disables the route entirely. 47 | 48 | 49 | ### Example 50 | In the example below, we are adding a fictitious dependency to the "create route" (POST) which requires the user to be 51 | logged in to create an object. At the same time, we are also independently adding an admin dependency to only the "delete all 52 | route" which limits the route's usage to only admin users. 53 | 54 | ```python 55 | MemoryCRUDRouter( 56 | schema=MySchema, 57 | create_route=[Depends(user)], 58 | delete_all_route=[Depends(admin)] 59 | ) 60 | ``` 61 | 62 | 63 | -------------------------------------------------------------------------------- /docs/en/docs/index.md: -------------------------------------------------------------------------------- 1 | <p align="center"> 2 | <img src="assets/banner.png" alt="CRUD Router Logo" style="margin-bottom: 20px" /> 3 | </p> 4 | <p align="center"> 5 | <em>⚡ Create CRUD routes with lighting speed</em> ⚡</br> 6 | <sub>A dynamic FastAPI router that automatically creates routes CRUD for your models</sub> 7 | </p> 8 | <p align="center"> 9 | <img alt="Tests" src="https://img.shields.io/github/actions/workflow/status/awtkns/fastapi-crudrouter/.github/workflows/pytest.yml?color=%2334D058" /> 10 | <img alt="Downloads" src="https://img.shields.io/pypi/dm/fastapi-crudrouter?color=%2334D058" /> 11 | <a href="https://pypi.org/project/fastapi-crudrouter" target="_blank"> 12 | <img src="https://img.shields.io/pypi/v/fastapi-crudrouter?color=%2334D058&label=pypi%20package" alt="Package version"> 13 | </a> 14 | <img alt="License" src="https://img.shields.io/github/license/awtkns/fastapi-crudrouter?color=%2334D058" /> 15 | </p> 16 | <p align="center"> 17 | <img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/fastapi-crudrouter"> 18 | </p> 19 | 20 | --- 21 | 22 | **Documentation**: <a href="https://fastapi-crudrouter.awtkns.com" target="_blank">https://fastapi-crudrouter.awtkns.com</a> 23 | 24 | **Source Code**: <a href="https://github.com/awtkns/fastapi-crudrouter" target="_blank">https://github.com/awtkns/fastapi-crudrouter</a> 25 | 26 | --- 27 | Tired of rewriting the same generic CRUD routes? Need to rapidly prototype a feature for a presentation 28 | or a hackathon? Thankfully, [fastapi-crudrouter](https://github.com/awtkns/fastapi-crudrouter) has your back. As an 29 | extension to the APIRouter included with [FastAPI](https://fastapi.tiangolo.com/), the FastAPI CRUDRouter will automatically 30 | generate and document your CRUD routes for you, all you have to do is pass your model and maybe your database connection. 31 | 32 | FastAPI-CRUDRouter is also **lightning fast**, well tested, and production ready. 33 | 34 | ## Installation 35 | 36 | <div class="termy"> 37 | 38 | ```console 39 | $ pip install fastapi-crudrouter 40 | 41 | ---> 100% 42 | ``` 43 | 44 | </div> 45 | 46 | 47 | 48 | ## Basic Usage 49 | Below is a simple example of what the CRUDRouter can do. In just ten lines of code, you can generate all 50 | the crud routes you need for any model. A full list of the routes generated can be found [here](./routing). 51 | 52 | ```python 53 | from pydantic import BaseModel 54 | from fastapi import FastAPI 55 | from fastapi_crudrouter import MemoryCRUDRouter as CRUDRouter 56 | 57 | class Potato(BaseModel): 58 | id: int 59 | color: str 60 | mass: float 61 | 62 | app = FastAPI() 63 | app.include_router(CRUDRouter(schema=Potato)) 64 | ``` 65 | 66 | ## Advanced Usage 67 | fastapi-crudrouter provides a number of features that allow you to get the most out of your automatically generated CRUD 68 | routes. Listed below are some highlights. 69 | 70 | - Automatic Pagination ([docs](https://fastapi-crudrouter.awtkns.com/pagination/)) 71 | - Ability to Provide Custom Create and Update Schemas ([docs](https://fastapi-crudrouter.awtkns.com/schemas/)) 72 | - Dynamic Generation of Create and Update Schemas ([docs](https://fastapi-crudrouter.awtkns.com/schemas/)) 73 | - Ability to Add, Customize, or Disable Specific Routes ([docs](https://fastapi-crudrouter.awtkns.com/routing/)) 74 | - Native Support for FastAPI Dependencies Injection ([docs](https://fastapi-crudrouter.awtkns.com/dependencies/)) 75 | 76 | ## Supported Backends / ORMs 77 | fastapi-crudrouter supports a number of backends / ORMs. Listed below are the backends currently supported. This list will 78 | likely grow in future releases. 79 | 80 | - In Memory ([docs](https://fastapi-crudrouter.awtkns.com/backends/memory/)) 81 | - SQLAlchemy ([docs](https://fastapi-crudrouter.awtkns.com/backends/sqlalchemy/)) 82 | - Databases (async) ([docs](https://fastapi-crudrouter.awtkns.com/backends/async/)) 83 | - Ormar (async) ([docs](https://fastapi-crudrouter.awtkns.com/backends/ormar/)) 84 | - Gino (async) ([docs](https://fastapi-crudrouter.awtkns.com/backends/gino/)) 85 | - Tortoise ORM (async) ([docs](https://fastapi-crudrouter.awtkns.com/backends/tortoise/)) 86 | 87 | ## OpenAPI Support 88 | 89 | !!! tip "Automatic OpenAPI Documentation" 90 | By default, all routes generated by the CRUDRouter will be documented according to OpenAPI spec. 91 | 92 | Below are the default routes created by the CRUDRouter shown in the generated OpenAPI documentation. 93 | 94 | ![OpenAPI Route Overview](https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/master/docs/en/docs/assets/RouteOverview.png) 95 | 96 | The CRUDRouter is able to dynamically generate detailed documentation based on the models given to it. 97 | 98 | ![OpenAPI Route Detail](https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/master/docs/en/docs/assets/RouteDetail.png) 99 | -------------------------------------------------------------------------------- /docs/en/docs/js/termy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ramirez aka Tiangalo 3 | * @license MIT 4 | */ 5 | 6 | function setupTermynal() { 7 | document.querySelectorAll(".use-termynal").forEach(node => { 8 | node.style.display = "block"; 9 | new Termynal(node, { 10 | lineDelay: 500 11 | }); 12 | }); 13 | const progressLiteralStart = "---> 100%"; 14 | const promptLiteralStart = "$ "; 15 | const customPromptLiteralStart = "# "; 16 | const termynalActivateClass = "termy"; 17 | let termynals = []; 18 | 19 | function createTermynals() { 20 | document 21 | .querySelectorAll(`.${termynalActivateClass} .highlight`) 22 | .forEach(node => { 23 | const text = node.textContent; 24 | const lines = text.split("\n"); 25 | const useLines = []; 26 | let buffer = []; 27 | function saveBuffer() { 28 | if (buffer.length) { 29 | let isBlankSpace = true; 30 | buffer.forEach(line => { 31 | if (line) { 32 | isBlankSpace = false; 33 | } 34 | }); 35 | dataValue = {}; 36 | if (isBlankSpace) { 37 | dataValue["delay"] = 0; 38 | } 39 | if (buffer[buffer.length - 1] === "") { 40 | // A last single <br> won't have effect 41 | // so put an additional one 42 | buffer.push(""); 43 | } 44 | const bufferValue = buffer.join("<br>"); 45 | dataValue["value"] = bufferValue; 46 | useLines.push(dataValue); 47 | buffer = []; 48 | } 49 | } 50 | for (let line of lines) { 51 | if (line === progressLiteralStart) { 52 | saveBuffer(); 53 | useLines.push({ 54 | type: "progress" 55 | }); 56 | } else if (line.startsWith(promptLiteralStart)) { 57 | saveBuffer(); 58 | const value = line.replace(promptLiteralStart, "").trimEnd(); 59 | useLines.push({ 60 | type: "input", 61 | value: value 62 | }); 63 | } else if (line.startsWith("// ")) { 64 | saveBuffer(); 65 | const value = "💬 " + line.replace("// ", "").trimEnd(); 66 | useLines.push({ 67 | value: value, 68 | class: "termynal-comment", 69 | delay: 0 70 | }); 71 | } else if (line.startsWith(customPromptLiteralStart)) { 72 | saveBuffer(); 73 | const promptStart = line.indexOf(promptLiteralStart); 74 | if (promptStart === -1) { 75 | console.error("Custom prompt found but no end delimiter", line) 76 | } 77 | const prompt = line.slice(0, promptStart).replace(customPromptLiteralStart, "") 78 | let value = line.slice(promptStart + promptLiteralStart.length); 79 | useLines.push({ 80 | type: "input", 81 | value: value, 82 | prompt: prompt 83 | }); 84 | } else { 85 | buffer.push(line); 86 | } 87 | } 88 | saveBuffer(); 89 | const div = document.createElement("div"); 90 | node.replaceWith(div); 91 | const termynal = new Termynal(div, { 92 | lineData: useLines, 93 | noInit: true, 94 | lineDelay: 500 95 | }); 96 | termynals.push(termynal); 97 | }); 98 | } 99 | 100 | function loadVisibleTermynals() { 101 | termynals = termynals.filter(termynal => { 102 | if (termynal.container.getBoundingClientRect().top - innerHeight <= 0) { 103 | termynal.init(); 104 | return false; 105 | } 106 | return true; 107 | }); 108 | } 109 | window.addEventListener("scroll", loadVisibleTermynals); 110 | createTermynals(); 111 | loadVisibleTermynals(); 112 | } 113 | 114 | setupTermynal() -------------------------------------------------------------------------------- /docs/en/docs/js/termynal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * termynal.js 3 | * A lightweight, modern and extensible animated terminal window, using 4 | * async/await. 5 | * 6 | * @author Ines Montani <ines@ines.io> 7 | * @version 0.0.1 8 | * @license MIT 9 | */ 10 | 11 | 'use strict'; 12 | 13 | /** Generate a terminal widget. */ 14 | class Termynal { 15 | /** 16 | * Construct the widget's settings. 17 | * @param {(string|Node)=} container - Query selector or container element. 18 | * @param {Object=} options - Custom settings. 19 | * @param {string} options.prefix - Prefix to use for data attributes. 20 | * @param {number} options.startDelay - Delay before animation, in ms. 21 | * @param {number} options.typeDelay - Delay between each typed character, in ms. 22 | * @param {number} options.lineDelay - Delay between each line, in ms. 23 | * @param {number} options.progressLength - Number of characters displayed as progress bar. 24 | * @param {string} options.progressChar – Character to use for progress bar, defaults to █. 25 | * @param {number} options.progressPercent - Max percent of progress. 26 | * @param {string} options.cursor – Character to use for cursor, defaults to ▋. 27 | * @param {Object[]} lineData - Dynamically loaded line data objects. 28 | * @param {boolean} options.noInit - Don't initialise the animation. 29 | */ 30 | constructor(container = '#termynal', options = {}) { 31 | this.container = (typeof container === 'string') ? document.querySelector(container) : container; 32 | this.pfx = `data-${options.prefix || 'ty'}`; 33 | this.originalStartDelay = this.startDelay = options.startDelay 34 | || parseFloat(this.container.getAttribute(`${this.pfx}-startDelay`)) || 600; 35 | this.originalTypeDelay = this.typeDelay = options.typeDelay 36 | || parseFloat(this.container.getAttribute(`${this.pfx}-typeDelay`)) || 90; 37 | this.originalLineDelay = this.lineDelay = options.lineDelay 38 | || parseFloat(this.container.getAttribute(`${this.pfx}-lineDelay`)) || 1500; 39 | this.progressLength = options.progressLength 40 | || parseFloat(this.container.getAttribute(`${this.pfx}-progressLength`)) || 40; 41 | this.progressChar = options.progressChar 42 | || this.container.getAttribute(`${this.pfx}-progressChar`) || '█'; 43 | this.progressPercent = options.progressPercent 44 | || parseFloat(this.container.getAttribute(`${this.pfx}-progressPercent`)) || 100; 45 | this.cursor = options.cursor 46 | || this.container.getAttribute(`${this.pfx}-cursor`) || '▋'; 47 | this.lineData = this.lineDataToElements(options.lineData || []); 48 | this.loadLines() 49 | if (!options.noInit) this.init() 50 | } 51 | 52 | loadLines() { 53 | // Load all the lines and create the container so that the size is fixed 54 | // Otherwise it would be changing and the user viewport would be constantly 55 | // moving as she/he scrolls 56 | const finish = this.generateFinish() 57 | finish.style.visibility = 'hidden' 58 | this.container.appendChild(finish) 59 | // Appends dynamically loaded lines to existing line elements. 60 | this.lines = [...this.container.querySelectorAll(`[${this.pfx}]`)].concat(this.lineData); 61 | for (let line of this.lines) { 62 | line.style.visibility = 'hidden' 63 | this.container.appendChild(line) 64 | } 65 | const restart = this.generateRestart() 66 | restart.style.visibility = 'hidden' 67 | this.container.appendChild(restart) 68 | this.container.setAttribute('data-termynal', ''); 69 | } 70 | 71 | /** 72 | * Initialise the widget, get lines, clear container and start animation. 73 | */ 74 | init() { 75 | /** 76 | * Calculates width and height of Termynal container. 77 | * If container is empty and lines are dynamically loaded, defaults to browser `auto` or CSS. 78 | */ 79 | const containerStyle = getComputedStyle(this.container); 80 | this.container.style.width = containerStyle.width !== '0px' ? 81 | containerStyle.width : undefined; 82 | this.container.style.minHeight = containerStyle.height !== '0px' ? 83 | containerStyle.height : undefined; 84 | 85 | this.container.setAttribute('data-termynal', ''); 86 | this.container.innerHTML = ''; 87 | for (let line of this.lines) { 88 | line.style.visibility = 'visible' 89 | } 90 | this.start(); 91 | } 92 | 93 | /** 94 | * Start the animation and rener the lines depending on their data attributes. 95 | */ 96 | async start() { 97 | this.addFinish() 98 | await this._wait(this.startDelay); 99 | 100 | for (let line of this.lines) { 101 | const type = line.getAttribute(this.pfx); 102 | const delay = line.getAttribute(`${this.pfx}-delay`) || this.lineDelay; 103 | 104 | if (type == 'input') { 105 | line.setAttribute(`${this.pfx}-cursor`, this.cursor); 106 | await this.type(line); 107 | await this._wait(delay); 108 | } 109 | 110 | else if (type == 'progress') { 111 | await this.progress(line); 112 | await this._wait(delay); 113 | } 114 | 115 | else { 116 | this.container.appendChild(line); 117 | await this._wait(delay); 118 | } 119 | 120 | line.removeAttribute(`${this.pfx}-cursor`); 121 | } 122 | this.addRestart() 123 | this.finishElement.style.visibility = 'hidden' 124 | this.lineDelay = this.originalLineDelay 125 | this.typeDelay = this.originalTypeDelay 126 | this.startDelay = this.originalStartDelay 127 | } 128 | 129 | generateRestart() { 130 | const restart = document.createElement('a') 131 | restart.onclick = (e) => { 132 | e.preventDefault() 133 | this.container.innerHTML = '' 134 | this.init() 135 | } 136 | restart.href = '#' 137 | restart.setAttribute('data-terminal-control', '') 138 | restart.innerHTML = "restart ↻" 139 | return restart 140 | } 141 | 142 | generateFinish() { 143 | const finish = document.createElement('a') 144 | finish.onclick = (e) => { 145 | e.preventDefault() 146 | this.lineDelay = 0 147 | this.typeDelay = 0 148 | this.startDelay = 0 149 | } 150 | finish.href = '#' 151 | finish.setAttribute('data-terminal-control', '') 152 | finish.innerHTML = "fast →" 153 | this.finishElement = finish 154 | return finish 155 | } 156 | 157 | addRestart() { 158 | const restart = this.generateRestart() 159 | this.container.appendChild(restart) 160 | } 161 | 162 | addFinish() { 163 | const finish = this.generateFinish() 164 | this.container.appendChild(finish) 165 | } 166 | 167 | /** 168 | * Animate a typed line. 169 | * @param {Node} line - The line element to render. 170 | */ 171 | async type(line) { 172 | const chars = [...line.textContent]; 173 | line.textContent = ''; 174 | this.container.appendChild(line); 175 | 176 | for (let char of chars) { 177 | const delay = line.getAttribute(`${this.pfx}-typeDelay`) || this.typeDelay; 178 | await this._wait(delay); 179 | line.textContent += char; 180 | } 181 | } 182 | 183 | /** 184 | * Animate a progress bar. 185 | * @param {Node} line - The line element to render. 186 | */ 187 | async progress(line) { 188 | const progressLength = line.getAttribute(`${this.pfx}-progressLength`) 189 | || this.progressLength; 190 | const progressChar = line.getAttribute(`${this.pfx}-progressChar`) 191 | || this.progressChar; 192 | const chars = progressChar.repeat(progressLength); 193 | const progressPercent = line.getAttribute(`${this.pfx}-progressPercent`) 194 | || this.progressPercent; 195 | line.textContent = ''; 196 | this.container.appendChild(line); 197 | 198 | for (let i = 1; i < chars.length + 1; i++) { 199 | await this._wait(this.typeDelay); 200 | const percent = Math.round(i / chars.length * 100); 201 | line.textContent = `${chars.slice(0, i)} ${percent}%`; 202 | if (percent>progressPercent) { 203 | break; 204 | } 205 | } 206 | } 207 | 208 | /** 209 | * Helper function for animation delays, called with `await`. 210 | * @param {number} time - Timeout, in ms. 211 | */ 212 | _wait(time) { 213 | return new Promise(resolve => setTimeout(resolve, time)); 214 | } 215 | 216 | /** 217 | * Converts line data objects into line elements. 218 | * 219 | * @param {Object[]} lineData - Dynamically loaded lines. 220 | * @param {Object} line - Line data object. 221 | * @returns {Element[]} - Array of line elements. 222 | */ 223 | lineDataToElements(lineData) { 224 | return lineData.map(line => { 225 | let div = document.createElement('div'); 226 | div.innerHTML = `<span ${this._attributes(line)}>${line.value || ''}</span>`; 227 | 228 | return div.firstElementChild; 229 | }); 230 | } 231 | 232 | /** 233 | * Helper function for generating attributes string. 234 | * 235 | * @param {Object} line - Line data object. 236 | * @returns {string} - String of attributes. 237 | */ 238 | _attributes(line) { 239 | let attrs = ''; 240 | for (let prop in line) { 241 | // Custom add class 242 | if (prop === 'class') { 243 | attrs += ` class=${line[prop]} ` 244 | continue 245 | } 246 | if (prop === 'type') { 247 | attrs += `${this.pfx}="${line[prop]}" ` 248 | } else if (prop !== 'value') { 249 | attrs += `${this.pfx}-${prop}="${line[prop]}" ` 250 | } 251 | } 252 | 253 | return attrs; 254 | } 255 | } 256 | 257 | /** 258 | * HTML API: If current script has container(s) specified, initialise Termynal. 259 | */ 260 | if (document.currentScript.hasAttribute('data-termynal-container')) { 261 | const containers = document.currentScript.getAttribute('data-termynal-container'); 262 | containers.split('|') 263 | .forEach(container => new Termynal(container)) 264 | } -------------------------------------------------------------------------------- /docs/en/docs/pagination.md: -------------------------------------------------------------------------------- 1 | The CRUDRouter is set up to automatically paginate your routes for you. You can use the `skip` and `limit` query parameters to 2 | paginate your results. 3 | 4 | **Skip**: 5 | Using the `skip` (int) parameter, you can skip a certain number of items before returning the items you want. 6 | 7 | **Limit**: 8 | Using the `limit` (int) parameter, the maximum number of items to be returned can be defined. 9 | 10 | !!! tip "Setting a Maximum Pagination Limit" 11 | When creating a new CRUDRouter you are able to set the maximum amount of items that will be returned per page. 12 | To do this, use the `paginate` kwarg when creating a new CRUDRouter as shown in the example below. 13 | 14 | ```python 15 | CRUDRouter( 16 | schema=MyPydanticModel, 17 | paginate=25 18 | ) 19 | ``` 20 | 21 | Above a new CRUDRouter is being created that will paginate items at 25 items per page. 22 | 23 | 24 | ### Example 25 | Shown below is an example usage of pagination; using `skip` and `limit` to paginate results from the backend. More information on how to 26 | `skip` and `limit` can be used with fastapi can be found [here](https://fastapi.tiangolo.com/tutorial/sql-databases/#crud-utils). 27 | 28 | === "Python" 29 | 30 | ```python 31 | import requests 32 | 33 | requests.get('http://localhost:5000/potatoes' params={ 34 | 'skip': 50, 35 | 'limit': 25 36 | }) 37 | ``` 38 | 39 | === "Bash" 40 | 41 | ```bash 42 | curl -X GET -G \ 43 | 'http://localhost:5000/potatoes' \ 44 | -d skip=50 \ 45 | -d limit=25 46 | ``` 47 | 48 | In the example above, 25 items on the third page are being returned from our fictitious CRUDRouter endpoint. It is the third 49 | page because we specified a `skip` of 50 items while having a `limit` of 25 items per page. If we were to want items on the fourth 50 | page we would simply have to increase the `skip` to 75. 51 | 52 | 53 | 54 | ### Validation 55 | CRUDRouter will return HTTP Validation error, status code 422, if any of these conditions are met: 56 | 57 | - The skip parameter is set to less than 0 58 | - The limit parameter is set to less than 1 59 | - The limit parameter is set to more than the maximum allowed number of records if a maximum is specified. 60 | 61 | Shown below is a sample validation error. In the example, a negative value for the `skip` parameter was supplied. 62 | ```json 63 | { 64 | "detail": { 65 | "detail": [ 66 | { 67 | "loc": ["query", "skip"], 68 | "msg": "skip query parameter must be greater or equal to zero", 69 | "type": "type_error.integer" 70 | } 71 | ] 72 | } 73 | } 74 | ``` 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /docs/en/docs/releases.md: -------------------------------------------------------------------------------- 1 | Release Notes 2 | === 3 | 4 | ## [v0.8.5 - Typing](https://github.com/awtkns/fastapi-crudrouter/releases/tag/v0.8.5) { .releases } 5 | 2022-01-27 6 | ### 🎉 Highlights 7 | With the release of v0.8.5 fastapi-crudrouter now officially supports both **Python 3.10** and **typed python**. This release also includes significant improvements to the documentation, test suite, and developer experience. 8 | 9 | Keep an eye of for the next release which will contain support for **async SQLAlchemy** ([#122](https://github.com/awtkns/fastapi-crudrouter/pull/122)). 10 | 11 | Big thanks to all contributors that made this possible! 12 | 13 | ### ✨ Features 14 | - Typed python support [#132](https://github.com/awtkns/fastapi-crudrouter/pull/132) [#111](https://github.com/awtkns/fastapi-crudrouter/pull/111) 15 | - Python 3.10 support [#114](https://github.com/awtkns/fastapi-crudrouter/pull/114) 16 | - Test suite now runs against multiple databases [#86](https://github.com/awtkns/fastapi-crudrouter/pull/86) 17 | - Documentation improvements [#79](https://github.com/awtkns/fastapi-crudrouter/pull/79) [#91](https://github.com/awtkns/fastapi-crudrouter/pull/91) [#117](https://github.com/awtkns/fastapi-crudrouter/pull/117) [#123](https://github.com/awtkns/fastapi-crudrouter/pull/123) [#124](https://github.com/awtkns/fastapi-crudrouter/pull/124) [#125](https://github.com/awtkns/fastapi-crudrouter/pull/125) [@andrewthetechie](https://github.com/andrewthetechie) 18 | - More informative exceptions [#94](https://github.com/awtkns/fastapi-crudrouter/pull/94) [#137](https://github.com/awtkns/fastapi-crudrouter/pull/137) 19 | - General test suite improvements [#96](https://github.com/awtkns/fastapi-crudrouter/pull/96) [#97](https://github.com/awtkns/fastapi-crudrouter/pull/97) 20 | 21 | ### 🐛 Bug Fixes 22 | - OrderBy not working correctly with Microsoft SQL Server [#88](https://github.com/awtkns/fastapi-crudrouter/pull/88) 23 | - 404 response not documented in OpenAPI spec [#104](https://github.com/awtkns/fastapi-crudrouter/pull/104) [@sondrelg](https://github.com/sondrelg) 24 | - DatabasesCRUDRouter not functioning for inserts and deletes with AsyncPG [#98](https://github.com/awtkns/fastapi-crudrouter/pull/98) 25 | 26 | **Full Changelog**: [`v0.8.0...v0.8.5`](https://github.com/awtkns/fastapi-crudrouter/compare/v0.8.0...v0.8.5) 27 | 28 | --- 29 | 30 | ## [v0.8.0 - Gino Backend](https://github.com/awtkns/fastapi-crudrouter/releases/tag/v0.8.0) { .releases } 31 | 2021-07-06 32 | ### 🎉 Highlights 33 | With the release of v0.6.0 fastapi-crudrouter **now supports [Gino](https://github.com/python-gino/gino)** as an async backend! When generating routes, GinoCRUDRouter will automatically tie into your database using your Gino models. To use it, simply pass your Gino database model, a database reference, and your pydantic. 34 | 35 | ```python 36 | GinoCRUDRouter( 37 | schema=MyPydanticModel, 38 | db_model=MyModel, 39 | db=db 40 | ) 41 | ``` 42 | 43 | Check out the [docs](https://fastapi-crudrouter.awtkns.com/backends/gino.html) for more details on how to use the GinoCRUDRouter. 44 | 45 | ### ✨ Features 46 | - Full Gino Support [@Turall](https://github.com/Turall) [#78](https://github.com/awtkns/fastapi-crudrouter/pull/78) 47 | - Documentation improvements [#69](https://github.com/awtkns/fastapi-crudrouter/pull/69) [#75](https://github.com/awtkns/fastapi-crudrouter/pull/75) 48 | 49 | ### 🐛 Bug Fixes 50 | - All Path Prefixes are now correctly lowercase [#64](https://github.com/awtkns/fastapi-crudrouter/pull/64) [#65](https://github.com/awtkns/fastapi-crudrouter/pull/65) 51 | 52 | 53 | --- 54 | 55 | ## [v0.7.0 - Advanced Dependencies ](https://github.com/awtkns/fastapi-crudrouter/releases/tag/v0.7.0) { .releases } 56 | 2021-04-18 57 | ### 🎉 Highlights 58 | With the release of v0.7.0 fastapi-crudrouter now provides the ability to set custom dependencies on a per route basis; a much requested feature. Prior to this release, it was only possible to set dependencies for all the routes at once. 59 | 60 | ```python 61 | MemoryCRUDRouter( 62 | schema=MySchema, 63 | create_route=[Depends(user)], 64 | delete_all_route=[Depends(admin)] 65 | ) 66 | ``` 67 | 68 | Shown above is a brief example on how limiting each route to specific access rights would work using this new feature. Check out the [docs](https://fastapi-crudrouter.awtkns.com/dependencies/) for more details. 69 | 70 | ### ✨ Features 71 | - Custom Dependencies Per Route [#37](https://github.com/awtkns/fastapi-crudrouter/pull/37) [#59](https://github.com/awtkns/fastapi-crudrouter/pull/59) [#60](https://github.com/awtkns/fastapi-crudrouter/pull/60) [@DorskFR](https://github.com/DorskFR) [@jm-moreau](https://github.com/jm-moreau) 72 | - Ability to Provide a List of Custom Tags for OpenAPI [#57](https://github.com/awtkns/fastapi-crudrouter/pull/57) [@jm-moreau](https://github.com/jm-moreau) 73 | - Improved Documentation [#52](https://github.com/awtkns/fastapi-crudrouter/pull/52) 74 | - Dark Mode for Documentation 75 | 76 | --- 77 | 78 | ## [v0.6.0 - Ormar Backend](https://github.com/awtkns/fastapi-crudrouter/releases/tag/v0.6.0) { .releases } 79 | 2021-03-26 80 | ### 🎉 Highlights 81 | With the release of v0.6.0 fastapi-crudrouter **now supports [ormar](https://github.com/collerek/ormar)** as an async backend! When generating routes, the OrmarCRUDRouter will automatically tie into your database using your ormar models. To use it, simply pass your ormar database model as the schema. 82 | 83 | ```python 84 | OrmarCRUDRouter( 85 | schema=MyPydanticModel, 86 | paginate=25 87 | ) 88 | ``` 89 | 90 | Check out the [docs](https://fastapi-crudrouter.awtkns.com/backends/ormar/) for more details on how to use the `OrmarCRUDRouter`. 91 | 92 | ### ✨ Features 93 | - Full Ormar Support [@collerek](https://github.com/collerek) [#46](https://github.com/awtkns/fastapi-crudrouter/pull/46) 94 | - Better handling of database errors in the update route [@sorXCode](https://github.com/sorXCode) [#48](https://github.com/awtkns/fastapi-crudrouter/pull/48) 95 | - Improved typing [#46](https://github.com/awtkns/fastapi-crudrouter/pull/46) [#43](https://github.com/awtkns/fastapi-crudrouter/pull/43) 96 | - Black, Flake8 and Mypy linting [#46](https://github.com/awtkns/fastapi-crudrouter/pull/46) 97 | - Additional Tests for nested models [#40](https://github.com/awtkns/fastapi-crudrouter/pull/40) 98 | 99 | ### 🐛 Bug Fixes 100 | - Pagination issues when max limit was set to null [@ethanhaid](https://github.com/ethanhaid) [#42](https://github.com/awtkns/fastapi-crudrouter/pull/42) 101 | 102 | --- 103 | 104 | ## [v0.5.0 - Pagination](https://github.com/awtkns/fastapi-crudrouter/releases/tag/v0.5.0) { .releases } 105 | 2021-03-07 106 | ### 🎉 Highlights 107 | With the release of v0.5.0 all CRUDRouters **now supports pagination**. All "get all" routes now accept `skip` and `limit` query parameters allowing you to easily paginate your routes. By default, no limit is set on the number of items returned by your routes. Should you wish to limit the number of items that a client can request, it can be done as shown below. 108 | 109 | ```python 110 | CRUDRouter( 111 | schema=MyPydanticModel, 112 | paginate=25 113 | ) 114 | ``` 115 | 116 | Check out the [docs](https://fastapi-crudrouter.awtkns.com/pagination/) on pagination for more information! 117 | 118 | ### ✨ Features 119 | - Pagination Support [#34](https://github.com/awtkns/fastapi-crudrouter/pull/34) 120 | - Ability to set custom update schemas [@andreipopovici](https://github.com/andreipopovici) [#31](https://github.com/awtkns/fastapi-crudrouter/pull/31) [#27](https://github.com/awtkns/fastapi-crudrouter/pull/27) 121 | - Better documentation of past releases [#36](https://github.com/awtkns/fastapi-crudrouter/pull/36) 122 | 123 | ### 🐛 Bug Fixes 124 | - Prefixing not available for versions of fastapi below v0.62.0 [#29](https://github.com/awtkns/fastapi-crudrouter/pull/29) [#30](https://github.com/awtkns/fastapi-crudrouter/pull/30) 125 | - Fixed an Import Issue SQLAlchemy and Integrity Errors [@andreipopovici](https://github.com/andreipopovici) [#33](https://github.com/awtkns/fastapi-crudrouter/pull/33) 126 | 127 | --- 128 | 129 | ## [v0.4.0 - Tortoise ORM Support](https://github.com/awtkns/fastapi-crudrouter/releases/tag/v0.4.0) { .releases } 130 | 2021-02-02 131 | ### ✨Features 132 | - Full support for tortoise-orm [#24](https://github.com/awtkns/fastapi-crudrouter/pull/24) 133 | - Dynamic pk/id types for get_one, delete_one, and update_one routes [#26](https://github.com/awtkns/fastapi-crudrouter/pull/26) 134 | 135 | ### 🐛 Bug Fixes 136 | - Fixed the summary for the delete one route [#16](https://github.com/awtkns/fastapi-crudrouter/pull/16) 137 | - Fixed import errors when certain packages are not installed [#21](https://github.com/awtkns/fastapi-crudrouter/pull/21) 138 | - Improved SQLA type hinting 139 | 140 | --- 141 | 142 | ## [v0.3.0 - Initial Release](https://github.com/awtkns/fastapi-crudrouter/releases/tag/v0.3.0) { .releases } 143 | 2021-01-04 144 | <p align="center"> 145 | <img src="https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/master/docs/en/docs/assets/logo.png" height="200" /> 146 | </p> 147 | <h1 align="center"> 148 | 🎉 Initial Release 🎉 149 | </h1> 150 | 151 | Tired of rewriting the same generic CRUD routes? Need to rapidly prototype a feature for a presentation or a hackathon? Thankfully, fastapi-crudrouter has your back. As an extension to the APIRouter included with FastAPI, the FastAPI CRUDRouter will automatically generate and document your CRUD routes for you. 152 | 153 | **Documentation**: <a href="https://fastapi-crudrouter.awtkns.com" target="_blank">https://fastapi-crudrouter.awtkns.com</a> 154 | 155 | **Source Code**: <a href="https://github.com/awtkns/fastapi-crudrouter" target="_blank">https://github.com/awtkns/fastapi-crudrouter</a> 156 | 157 | 158 | ### Installation 159 | ```python 160 | pip install fastapi_crudrouter 161 | ``` 162 | 163 | ### Usage 164 | Below is a simple example of what the CRUDRouter can do. In just ten lines of code, you can generate all the crud routes you need for any model. A full list of the routes generated can be found here. 165 | ```python 166 | from pydantic import BaseModel 167 | from fastapi import FastAPI 168 | from fastapi_crudrouter import MemoryCRUDRouter as CRUDRouter 169 | 170 | class Potato(BaseModel): 171 | id: int 172 | color: str 173 | mass: float 174 | 175 | app = FastAPI() 176 | app.include_router(CRUDRouter(model=Potato)) 177 | ``` 178 | 179 | ### Features 180 | - Automatic pydantic model based route generation and documentation ([Docs](https://fastapi-crudrouter.awtkns.com/routing/)) 181 | - Ability to customize any of the generated routes ([Docs](https://fastapi-crudrouter.awtkns.com/routing/#overriding-routes)) 182 | - Authorization and FastAPI dependency support ([Docs](https://fastapi-crudrouter.awtkns.com/dependencies/)) 183 | - Support for both async and non-async relational databases using SQLAlchemy ([Docs](https://fastapi-crudrouter.awtkns.com/backends/sqlalchemy/)) 184 | - Extensive documentation. 185 | - And much more 😎 -------------------------------------------------------------------------------- /docs/en/docs/routing.md: -------------------------------------------------------------------------------- 1 | Automatic route generation is the meat and potatoes of CRUDRouter's features. Detail below is how you can prefix, customize, 2 | and disable any routes generated by the CRUDRouter. 3 | 4 | ## Default Routes 5 | By default, the CRUDRouter will generate the six routes below for you. 6 | 7 | | Route | Method | Description 8 | | ------------ | -------- | ---- 9 | | `/` | `GET` | Get all the resources 10 | | `/` | `POST` | Create a new resource 11 | | `/` | `DELETE` | Delete all the resources 12 | | `/{item_id}` | `GET` | Get an existing resource matching the given `item_id` 13 | | `/{item_id}` | `PUT` | Update an existing resource matching the given `item_id` 14 | | `/{item_id}` | `DELETE` | Delete an existing resource matching the given `item_id` 15 | 16 | !!! note "Route URLs" 17 | Note that the route url is prefixed by the defined prefix. 18 | 19 | **Example:** If the CRUDRouter's prefix is set as *potato* and I want to update a specific potato the route I want to access is 20 | `/potato/my_potato_id` where *my_potato_id* is the ID of the potato. 21 | 22 | ## Prefixes 23 | Depending on which CRUDRouter you are using, the CRUDRouter will try to automatically generate a suitable prefix for your 24 | model. By default, the [MemoryCRUDRouter](backends/memory.md) will use the pydantic model's name as the prefix. However, 25 | the [SQLAlchemyCRUDRouter](backends/sqlalchemy.md) will use the model's table name as the prefix. 26 | 27 | !!! tip "Custom Prefixes" 28 | You are also able to set custom prefixes with the `prefix` kwarg when creating your CRUDRouter. This can be done like so: 29 | `router = CRUDRouter(model=mymodel, prefix='carrot')` 30 | 31 | ## Disabling Routes 32 | Routes can be disabled from generating with a key word argument (kwarg) when creating your CRUDRouter. The valid kwargs 33 | are shown below. 34 | 35 | | Argument | Default | Description 36 | | ---------------- | ------ | --- 37 | | get_all_route | True | Setting this to false will prevent the get all route from generating 38 | | get_one_route | True | Setting this to false will prevent the get one route from generating 39 | | delete_all_route | True | Setting this to false will prevent the delete all route from generating 40 | | delete_one_route | True | Setting this to false will prevent the delete one route from generating 41 | | create_route | True | Setting this to false will prevent the create route from generating 42 | | update_route | True | Setting this to false will prevent the update route from generating 43 | 44 | As an example, the *delete all* route can be disabled by doing the following: 45 | ```python 46 | router = MemoryCRUDRouter(schema=MyModel, delete_all_route=False) 47 | ``` 48 | 49 | !!! tip "Custom Dependencies" 50 | Instead to passing a bool to the arguments listed about, you can also pass a sequence of custom dependencies to be 51 | applied to each route. See the docs on [dependencies](dependencies.md) for more details. 52 | 53 | 54 | ## Overriding Routes 55 | Should you need to add custom functionality to any of your routes any of the included routers allows you to do so. 56 | Should you wish to disable a route from being generated, it can be done [here](../routing/#disabling-routes). 57 | 58 | Routes in the CRUDRouter can be overridden by using the standard fastapi route decorators. These include: 59 | 60 | - `@router.get(path: str, *args, **kwargs)` 61 | - `@router.post(path: str, *args, **kwargs)` 62 | - `@router.put(path: str, *args, **kwargs)` 63 | - `@router.delete(path: str, *args, **kwargs)` 64 | - `@router.api_route(path: str, methods: List[str] = ['GET'], *args, **kwargs)` 65 | 66 | !!! tip 67 | All of CRUDRouter's are a subclass of fastapi's [APIRouter](https://fastapi.tiangolo.com/tutorial/bigger-applications/#apirouter) 68 | meaning that they can be customized to your heart's content. 69 | 70 | ### Overriding Example 71 | Below is an example where we are overriding the routes `/potato/{item_id}` and `/potato` while using the MemoryCRUDRouter. 72 | 73 | ```python 74 | from pydantic import BaseModel 75 | from fastapi import FastAPI 76 | from fastapi_crudrouter import MemoryCRUDRouter as CRUDRouter 77 | 78 | class Potato(BaseModel): 79 | id: int 80 | color: str 81 | mass: float 82 | 83 | app = FastAPI() 84 | router = CRUDRouter(schema=Potato) 85 | 86 | @router.get('') 87 | def overloaded_get_all(): 88 | return 'My overloaded route that returns all the items' 89 | 90 | @router.get('/{item_id}') 91 | def overloaded_get_one(): 92 | return 'My overloaded route that returns one item' 93 | 94 | app.include_router(router) 95 | ``` 96 | -------------------------------------------------------------------------------- /docs/en/docs/schemas.md: -------------------------------------------------------------------------------- 1 | The CRUDRouter is able to generate and document your routes based on the schema, a 2 | [pydantic model](https://pydantic-docs.helpmanual.io/usage/models/#basic-model-usage), that is passed to it. 3 | 4 | In general, the all provided CRUDRouter's have the option to pass both a `schema` and a `create` schema to it. If no 5 | create schema is provided, the CRUDRouter will automatically generate one. Optionally you can also pass an `update` schema 6 | allowing for custom update behavior. 7 | 8 | ```python 9 | CRUDRouter( 10 | schema=MyPydanticModel, 11 | create_schema=MyPydanticCreateModel, 12 | update_schema=MyPydanticUpdateModel 13 | ) 14 | ``` 15 | 16 | !!! tip "Automatic Create and Update Schema Generation" 17 | Leaving the create and/or update schema argument blank when creating your CRUDRouter will result in the crud router automatically 18 | generating and documenting a create and/or update schema in your routes. In doing so, it automatically removes any field which 19 | matches the primary key in the database as this will be generated server side. 20 | 21 | ## Create Schemas 22 | Create schemas are models which typically don't include fields that are generated by a database or other backends. An example of this 23 | is an `id` field in a model. 24 | 25 | ```python 26 | from pydantic import BaseModel 27 | 28 | class Potato(BaseModel): 29 | id: int 30 | color: str 31 | mass: float 32 | 33 | class CreatePotato(BaseModel): 34 | color: str 35 | mass: float 36 | ``` 37 | 38 | ## Update Schemas 39 | Update schemas allow you to limit which fields can be updated. As an example, the update schema below will only allow you to 40 | update the `color` field when used in the CRUDRouter. 41 | 42 | ```python 43 | from pydantic import BaseModel 44 | 45 | class Potato(BaseModel): 46 | id: int 47 | color: str 48 | mass: float 49 | 50 | # Allowing the user to only update the color 51 | class UpdatePotato(BaseModel): 52 | color: str 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/en/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: FastAPI CRUD Router 2 | repo_url: https://github.com/awtkns/fastapi-crudrouter 3 | repo_name: awtkns/fastapi-crudrouter 4 | edit_uri: '' 5 | 6 | theme: 7 | name: material 8 | features: 9 | - navigation.expand 10 | - content.code.copy 11 | palette: 12 | - scheme: default 13 | primary: indigo 14 | accent: amber 15 | toggle: 16 | icon: material/weather-sunny 17 | name: Switch to dark mode 18 | - scheme: slate 19 | primary: blue 20 | accent: amber 21 | toggle: 22 | icon: material/weather-night 23 | name: Switch to light mode 24 | icon: 25 | repo: fontawesome/brands/github-alt 26 | logo: assets/bolt.svg 27 | favicon: assets/bolt-grad.svg 28 | language: en 29 | 30 | nav: 31 | - Overview: index.md 32 | - Schemas: schemas.md 33 | - Backends: 34 | - In Memory: backends/memory.md 35 | - SQLAlchemy: backends/sqlalchemy.md 36 | - Databases (async): backends/async.md 37 | - Gino (async): backends/gino.md 38 | - Ormar (async): backends/ormar.md 39 | - Tortoise (async): backends/tortoise.md 40 | - Routing: routing.md 41 | - Pagination: pagination.md 42 | - Dependencies: dependencies.md 43 | - Contributing: contributing.md 44 | - Releases: releases.md 45 | 46 | markdown_extensions: 47 | - extra 48 | - admonition 49 | - pymdownx.highlight 50 | - pymdownx.inlinehilite 51 | - pymdownx.superfences 52 | - pymdownx.tabbed 53 | 54 | extra: 55 | analytics: 56 | provider: google 57 | property: UA-186315536-1 58 | social: 59 | - icon: fontawesome/brands/github-alt 60 | link: https://github.com/awtkns/fastapi-crudrouter 61 | - icon: fontawesome/brands/linkedin 62 | link: https://www.linkedin.com/in/awtkns/ 63 | - icon: fontawesome/solid/globe 64 | link: https://github.com/awtkns 65 | 66 | extra_css: 67 | - css/main.css 68 | - css/termynal.css 69 | extra_javascript: 70 | - js/termynal.js 71 | - js/termy.js 72 | -------------------------------------------------------------------------------- /docs/en/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block announce %} 4 | <a class="announce" href="/releases/#v070-advanced-dependencies" target="_blank"> 5 | 🎉 v0.7.0 Released - Advanced Dependency Support 🎉 6 | </a> 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /docs/en/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "cleanUrls": true, 3 | "trailingSlash": false, 4 | "redirects": [ 5 | { "source": "/backends", "destination": "/" }, 6 | { "source": "/memory", "destination": "backends/memory" }, 7 | { "source": "/sqlalchemy", "destination": "backends/sqlalchemy" }, 8 | { "source": "/async", "destination": "backends/async" }, 9 | { "source": "/gino", "destination": "backends/gino" }, 10 | { "source": "/ormar", "destination": "backends/ormar" }, 11 | { "source": "/tortoise", "destination": "backends/tortoise" } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /fastapi_crudrouter/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import ( 2 | DatabasesCRUDRouter, 3 | GinoCRUDRouter, 4 | MemoryCRUDRouter, 5 | OrmarCRUDRouter, 6 | SQLAlchemyCRUDRouter, 7 | TortoiseCRUDRouter, 8 | ) 9 | 10 | from ._version import __version__ # noqa: F401 11 | 12 | __all__ = [ 13 | "MemoryCRUDRouter", 14 | "SQLAlchemyCRUDRouter", 15 | "DatabasesCRUDRouter", 16 | "TortoiseCRUDRouter", 17 | "OrmarCRUDRouter", 18 | "GinoCRUDRouter", 19 | ] 20 | -------------------------------------------------------------------------------- /fastapi_crudrouter/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.8.6" 2 | -------------------------------------------------------------------------------- /fastapi_crudrouter/core/__init__.py: -------------------------------------------------------------------------------- 1 | from . import _utils 2 | from ._base import NOT_FOUND, CRUDGenerator 3 | from .databases import DatabasesCRUDRouter 4 | from .gino_starlette import GinoCRUDRouter 5 | from .mem import MemoryCRUDRouter 6 | from .ormar import OrmarCRUDRouter 7 | from .sqlalchemy import SQLAlchemyCRUDRouter 8 | from .tortoise import TortoiseCRUDRouter 9 | 10 | __all__ = [ 11 | "_utils", 12 | "CRUDGenerator", 13 | "NOT_FOUND", 14 | "MemoryCRUDRouter", 15 | "SQLAlchemyCRUDRouter", 16 | "DatabasesCRUDRouter", 17 | "TortoiseCRUDRouter", 18 | "OrmarCRUDRouter", 19 | "GinoCRUDRouter", 20 | ] 21 | -------------------------------------------------------------------------------- /fastapi_crudrouter/core/_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Callable, Generic, List, Optional, Type, Union 3 | 4 | from fastapi import APIRouter, HTTPException 5 | from fastapi.types import DecoratedCallable 6 | 7 | from ._types import T, DEPENDENCIES 8 | from ._utils import pagination_factory, schema_factory 9 | 10 | NOT_FOUND = HTTPException(404, "Item not found") 11 | 12 | 13 | class CRUDGenerator(Generic[T], APIRouter, ABC): 14 | schema: Type[T] 15 | create_schema: Type[T] 16 | update_schema: Type[T] 17 | _base_path: str = "/" 18 | 19 | def __init__( 20 | self, 21 | schema: Type[T], 22 | create_schema: Optional[Type[T]] = None, 23 | update_schema: Optional[Type[T]] = None, 24 | prefix: Optional[str] = None, 25 | tags: Optional[List[str]] = None, 26 | paginate: Optional[int] = None, 27 | get_all_route: Union[bool, DEPENDENCIES] = True, 28 | get_one_route: Union[bool, DEPENDENCIES] = True, 29 | create_route: Union[bool, DEPENDENCIES] = True, 30 | update_route: Union[bool, DEPENDENCIES] = True, 31 | delete_one_route: Union[bool, DEPENDENCIES] = True, 32 | delete_all_route: Union[bool, DEPENDENCIES] = True, 33 | **kwargs: Any, 34 | ) -> None: 35 | 36 | self.schema = schema 37 | self.pagination = pagination_factory(max_limit=paginate) 38 | self._pk: str = self._pk if hasattr(self, "_pk") else "id" 39 | self.create_schema = ( 40 | create_schema 41 | if create_schema 42 | else schema_factory(self.schema, pk_field_name=self._pk, name="Create") 43 | ) 44 | self.update_schema = ( 45 | update_schema 46 | if update_schema 47 | else schema_factory(self.schema, pk_field_name=self._pk, name="Update") 48 | ) 49 | 50 | prefix = str(prefix if prefix else self.schema.__name__).lower() 51 | prefix = self._base_path + prefix.strip("/") 52 | tags = tags or [prefix.strip("/").capitalize()] 53 | 54 | super().__init__(prefix=prefix, tags=tags, **kwargs) 55 | 56 | if get_all_route: 57 | self._add_api_route( 58 | "", 59 | self._get_all(), 60 | methods=["GET"], 61 | response_model=Optional[List[self.schema]], # type: ignore 62 | summary="Get All", 63 | dependencies=get_all_route, 64 | ) 65 | 66 | if create_route: 67 | self._add_api_route( 68 | "", 69 | self._create(), 70 | methods=["POST"], 71 | response_model=self.schema, 72 | summary="Create One", 73 | dependencies=create_route, 74 | ) 75 | 76 | if delete_all_route: 77 | self._add_api_route( 78 | "", 79 | self._delete_all(), 80 | methods=["DELETE"], 81 | response_model=Optional[List[self.schema]], # type: ignore 82 | summary="Delete All", 83 | dependencies=delete_all_route, 84 | ) 85 | 86 | if get_one_route: 87 | self._add_api_route( 88 | "/{item_id}", 89 | self._get_one(), 90 | methods=["GET"], 91 | response_model=self.schema, 92 | summary="Get One", 93 | dependencies=get_one_route, 94 | error_responses=[NOT_FOUND], 95 | ) 96 | 97 | if update_route: 98 | self._add_api_route( 99 | "/{item_id}", 100 | self._update(), 101 | methods=["PUT"], 102 | response_model=self.schema, 103 | summary="Update One", 104 | dependencies=update_route, 105 | error_responses=[NOT_FOUND], 106 | ) 107 | 108 | if delete_one_route: 109 | self._add_api_route( 110 | "/{item_id}", 111 | self._delete_one(), 112 | methods=["DELETE"], 113 | response_model=self.schema, 114 | summary="Delete One", 115 | dependencies=delete_one_route, 116 | error_responses=[NOT_FOUND], 117 | ) 118 | 119 | def _add_api_route( 120 | self, 121 | path: str, 122 | endpoint: Callable[..., Any], 123 | dependencies: Union[bool, DEPENDENCIES], 124 | error_responses: Optional[List[HTTPException]] = None, 125 | **kwargs: Any, 126 | ) -> None: 127 | dependencies = [] if isinstance(dependencies, bool) else dependencies 128 | responses: Any = ( 129 | {err.status_code: {"detail": err.detail} for err in error_responses} 130 | if error_responses 131 | else None 132 | ) 133 | 134 | super().add_api_route( 135 | path, endpoint, dependencies=dependencies, responses=responses, **kwargs 136 | ) 137 | 138 | def api_route( 139 | self, path: str, *args: Any, **kwargs: Any 140 | ) -> Callable[[DecoratedCallable], DecoratedCallable]: 141 | """Overrides and exiting route if it exists""" 142 | methods = kwargs["methods"] if "methods" in kwargs else ["GET"] 143 | self.remove_api_route(path, methods) 144 | return super().api_route(path, *args, **kwargs) 145 | 146 | def get( 147 | self, path: str, *args: Any, **kwargs: Any 148 | ) -> Callable[[DecoratedCallable], DecoratedCallable]: 149 | self.remove_api_route(path, ["Get"]) 150 | return super().get(path, *args, **kwargs) 151 | 152 | def post( 153 | self, path: str, *args: Any, **kwargs: Any 154 | ) -> Callable[[DecoratedCallable], DecoratedCallable]: 155 | self.remove_api_route(path, ["POST"]) 156 | return super().post(path, *args, **kwargs) 157 | 158 | def put( 159 | self, path: str, *args: Any, **kwargs: Any 160 | ) -> Callable[[DecoratedCallable], DecoratedCallable]: 161 | self.remove_api_route(path, ["PUT"]) 162 | return super().put(path, *args, **kwargs) 163 | 164 | def delete( 165 | self, path: str, *args: Any, **kwargs: Any 166 | ) -> Callable[[DecoratedCallable], DecoratedCallable]: 167 | self.remove_api_route(path, ["DELETE"]) 168 | return super().delete(path, *args, **kwargs) 169 | 170 | def remove_api_route(self, path: str, methods: List[str]) -> None: 171 | methods_ = set(methods) 172 | 173 | for route in self.routes: 174 | if ( 175 | route.path == f"{self.prefix}{path}" # type: ignore 176 | and route.methods == methods_ # type: ignore 177 | ): 178 | self.routes.remove(route) 179 | 180 | @abstractmethod 181 | def _get_all(self, *args: Any, **kwargs: Any) -> Callable[..., Any]: 182 | raise NotImplementedError 183 | 184 | @abstractmethod 185 | def _get_one(self, *args: Any, **kwargs: Any) -> Callable[..., Any]: 186 | raise NotImplementedError 187 | 188 | @abstractmethod 189 | def _create(self, *args: Any, **kwargs: Any) -> Callable[..., Any]: 190 | raise NotImplementedError 191 | 192 | @abstractmethod 193 | def _update(self, *args: Any, **kwargs: Any) -> Callable[..., Any]: 194 | raise NotImplementedError 195 | 196 | @abstractmethod 197 | def _delete_one(self, *args: Any, **kwargs: Any) -> Callable[..., Any]: 198 | raise NotImplementedError 199 | 200 | @abstractmethod 201 | def _delete_all(self, *args: Any, **kwargs: Any) -> Callable[..., Any]: 202 | raise NotImplementedError 203 | 204 | def _raise(self, e: Exception, status_code: int = 422) -> HTTPException: 205 | raise HTTPException(422, ", ".join(e.args)) from e 206 | 207 | @staticmethod 208 | def get_routes() -> List[str]: 209 | return ["get_all", "create", "delete_all", "get_one", "update", "delete_one"] 210 | -------------------------------------------------------------------------------- /fastapi_crudrouter/core/_types.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, TypeVar, Optional, Sequence 2 | 3 | from fastapi.params import Depends 4 | from pydantic import BaseModel 5 | 6 | PAGINATION = Dict[str, Optional[int]] 7 | PYDANTIC_SCHEMA = BaseModel 8 | 9 | T = TypeVar("T", bound=BaseModel) 10 | DEPENDENCIES = Optional[Sequence[Depends]] 11 | -------------------------------------------------------------------------------- /fastapi_crudrouter/core/_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type, Any 2 | 3 | from fastapi import Depends, HTTPException 4 | from pydantic import create_model 5 | 6 | from ._types import T, PAGINATION, PYDANTIC_SCHEMA 7 | 8 | 9 | class AttrDict(dict): # type: ignore 10 | def __init__(self, *args, **kwargs) -> None: # type: ignore 11 | super(AttrDict, self).__init__(*args, **kwargs) 12 | self.__dict__ = self 13 | 14 | 15 | def get_pk_type(schema: Type[PYDANTIC_SCHEMA], pk_field: str) -> Any: 16 | try: 17 | return schema.__fields__[pk_field].type_ 18 | except KeyError: 19 | return int 20 | 21 | 22 | def schema_factory( 23 | schema_cls: Type[T], pk_field_name: str = "id", name: str = "Create" 24 | ) -> Type[T]: 25 | """ 26 | Is used to create a CreateSchema which does not contain pk 27 | """ 28 | 29 | fields = { 30 | f.name: (f.type_, ...) 31 | for f in schema_cls.__fields__.values() 32 | if f.name != pk_field_name 33 | } 34 | 35 | name = schema_cls.__name__ + name 36 | schema: Type[T] = create_model(__model_name=name, **fields) # type: ignore 37 | return schema 38 | 39 | 40 | def create_query_validation_exception(field: str, msg: str) -> HTTPException: 41 | return HTTPException( 42 | 422, 43 | detail={ 44 | "detail": [ 45 | {"loc": ["query", field], "msg": msg, "type": "type_error.integer"} 46 | ] 47 | }, 48 | ) 49 | 50 | 51 | def pagination_factory(max_limit: Optional[int] = None) -> Any: 52 | """ 53 | Created the pagination dependency to be used in the router 54 | """ 55 | 56 | def pagination(skip: int = 0, limit: Optional[int] = max_limit) -> PAGINATION: 57 | if skip < 0: 58 | raise create_query_validation_exception( 59 | field="skip", 60 | msg="skip query parameter must be greater or equal to zero", 61 | ) 62 | 63 | if limit is not None: 64 | if limit <= 0: 65 | raise create_query_validation_exception( 66 | field="limit", msg="limit query parameter must be greater then zero" 67 | ) 68 | 69 | elif max_limit and max_limit < limit: 70 | raise create_query_validation_exception( 71 | field="limit", 72 | msg=f"limit query parameter must be less then {max_limit}", 73 | ) 74 | 75 | return {"skip": skip, "limit": limit} 76 | 77 | return Depends(pagination) 78 | -------------------------------------------------------------------------------- /fastapi_crudrouter/core/databases.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Any, 3 | Callable, 4 | List, 5 | Mapping, 6 | Type, 7 | Coroutine, 8 | Optional, 9 | Union, 10 | ) 11 | 12 | from fastapi import HTTPException 13 | 14 | from . import CRUDGenerator, NOT_FOUND 15 | from ._types import PAGINATION, PYDANTIC_SCHEMA, DEPENDENCIES 16 | from ._utils import AttrDict, get_pk_type 17 | 18 | try: 19 | from sqlalchemy.sql.schema import Table 20 | from databases.core import Database 21 | except ImportError: 22 | Database = None # type: ignore 23 | Table = None 24 | databases_installed = False 25 | else: 26 | databases_installed = True 27 | 28 | Model = Mapping[Any, Any] 29 | CALLABLE = Callable[..., Coroutine[Any, Any, Model]] 30 | CALLABLE_LIST = Callable[..., Coroutine[Any, Any, List[Model]]] 31 | 32 | 33 | def pydantify_record( 34 | models: Union[Model, List[Model]] 35 | ) -> Union[AttrDict, List[AttrDict]]: 36 | if type(models) is list: 37 | return [AttrDict(**dict(model)) for model in models] 38 | else: 39 | return AttrDict(**dict(models)) # type: ignore 40 | 41 | 42 | class DatabasesCRUDRouter(CRUDGenerator[PYDANTIC_SCHEMA]): 43 | def __init__( 44 | self, 45 | schema: Type[PYDANTIC_SCHEMA], 46 | table: "Table", 47 | database: "Database", 48 | create_schema: Optional[Type[PYDANTIC_SCHEMA]] = None, 49 | update_schema: Optional[Type[PYDANTIC_SCHEMA]] = None, 50 | prefix: Optional[str] = None, 51 | tags: Optional[List[str]] = None, 52 | paginate: Optional[int] = None, 53 | get_all_route: Union[bool, DEPENDENCIES] = True, 54 | get_one_route: Union[bool, DEPENDENCIES] = True, 55 | create_route: Union[bool, DEPENDENCIES] = True, 56 | update_route: Union[bool, DEPENDENCIES] = True, 57 | delete_one_route: Union[bool, DEPENDENCIES] = True, 58 | delete_all_route: Union[bool, DEPENDENCIES] = True, 59 | **kwargs: Any 60 | ) -> None: 61 | assert ( 62 | databases_installed 63 | ), "Databases and SQLAlchemy must be installed to use the DatabasesCRUDRouter." 64 | 65 | self.table = table 66 | self.db = database 67 | self._pk = table.primary_key.columns.values()[0].name 68 | self._pk_col = self.table.c[self._pk] 69 | self._pk_type: type = get_pk_type(schema, self._pk) 70 | 71 | super().__init__( 72 | schema=schema, 73 | create_schema=create_schema, 74 | update_schema=update_schema, 75 | prefix=prefix or table.name, 76 | tags=tags, 77 | paginate=paginate, 78 | get_all_route=get_all_route, 79 | get_one_route=get_one_route, 80 | create_route=create_route, 81 | update_route=update_route, 82 | delete_one_route=delete_one_route, 83 | delete_all_route=delete_all_route, 84 | **kwargs 85 | ) 86 | 87 | def _get_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 88 | async def route( 89 | pagination: PAGINATION = self.pagination, 90 | ) -> List[Model]: 91 | skip, limit = pagination.get("skip"), pagination.get("limit") 92 | 93 | query = self.table.select().limit(limit).offset(skip) 94 | return pydantify_record(await self.db.fetch_all(query)) # type: ignore 95 | 96 | return route 97 | 98 | def _get_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 99 | async def route(item_id: self._pk_type) -> Model: # type: ignore 100 | query = self.table.select().where(self._pk_col == item_id) 101 | model = await self.db.fetch_one(query) 102 | 103 | if model: 104 | return pydantify_record(model) # type: ignore 105 | else: 106 | raise NOT_FOUND 107 | 108 | return route 109 | 110 | def _create(self, *args: Any, **kwargs: Any) -> CALLABLE: 111 | async def route( 112 | schema: self.create_schema, # type: ignore 113 | ) -> Model: 114 | query = self.table.insert() 115 | 116 | try: 117 | rid = await self.db.execute(query=query, values=schema.dict()) 118 | if type(rid) is not self._pk_type: 119 | rid = getattr(schema, self._pk, rid) 120 | 121 | return await self._get_one()(rid) 122 | except Exception: 123 | raise HTTPException(422, "Key already exists") from None 124 | 125 | return route 126 | 127 | def _update(self, *args: Any, **kwargs: Any) -> CALLABLE: 128 | async def route( 129 | item_id: self._pk_type, schema: self.update_schema # type: ignore 130 | ) -> Model: 131 | query = self.table.update().where(self._pk_col == item_id) 132 | 133 | try: 134 | await self.db.fetch_one( 135 | query=query, values=schema.dict(exclude={self._pk}) 136 | ) 137 | return await self._get_one()(item_id) 138 | except Exception as e: 139 | raise NOT_FOUND from e 140 | 141 | return route 142 | 143 | def _delete_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 144 | async def route() -> List[Model]: 145 | query = self.table.delete() 146 | await self.db.execute(query=query) 147 | 148 | return await self._get_all()(pagination={"skip": 0, "limit": None}) 149 | 150 | return route 151 | 152 | def _delete_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 153 | async def route(item_id: self._pk_type) -> Model: # type: ignore 154 | query = self.table.delete().where(self._pk_col == item_id) 155 | 156 | try: 157 | row = await self._get_one()(item_id) 158 | await self.db.execute(query=query) 159 | return row 160 | except Exception as e: 161 | raise NOT_FOUND from e 162 | 163 | return route 164 | -------------------------------------------------------------------------------- /fastapi_crudrouter/core/gino_starlette.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, List, Optional, Type, Union, Coroutine 2 | 3 | from fastapi import HTTPException 4 | 5 | from . import NOT_FOUND, CRUDGenerator, _utils 6 | from ._types import DEPENDENCIES, PAGINATION 7 | from ._types import PYDANTIC_SCHEMA as SCHEMA 8 | 9 | try: 10 | from asyncpg.exceptions import UniqueViolationError 11 | from gino import Gino 12 | from sqlalchemy.exc import IntegrityError 13 | from sqlalchemy.ext.declarative import DeclarativeMeta as Model 14 | except ImportError: 15 | Model = None 16 | IntegrityError = None 17 | UniqueViolationError = None 18 | Gino = None 19 | gino_installed = False 20 | else: 21 | gino_installed = True 22 | 23 | CALLABLE = Callable[..., Coroutine[Any, Any, Model]] 24 | CALLABLE_LIST = Callable[..., Coroutine[Any, Any, List[Model]]] 25 | 26 | 27 | class GinoCRUDRouter(CRUDGenerator[SCHEMA]): 28 | def __init__( 29 | self, 30 | schema: Type[SCHEMA], 31 | db_model: Model, 32 | db: "Gino", 33 | create_schema: Optional[Type[SCHEMA]] = None, 34 | update_schema: Optional[Type[SCHEMA]] = None, 35 | prefix: Optional[str] = None, 36 | tags: Optional[List[str]] = None, 37 | paginate: Optional[int] = None, 38 | get_all_route: Union[bool, DEPENDENCIES] = True, 39 | get_one_route: Union[bool, DEPENDENCIES] = True, 40 | create_route: Union[bool, DEPENDENCIES] = True, 41 | update_route: Union[bool, DEPENDENCIES] = True, 42 | delete_one_route: Union[bool, DEPENDENCIES] = True, 43 | delete_all_route: Union[bool, DEPENDENCIES] = True, 44 | **kwargs: Any 45 | ) -> None: 46 | assert gino_installed, "Gino must be installed to use the GinoCRUDRouter." 47 | 48 | self.db_model = db_model 49 | self.db = db 50 | self._pk: str = db_model.__table__.primary_key.columns.keys()[0] 51 | self._pk_type: type = _utils.get_pk_type(schema, self._pk) 52 | 53 | super().__init__( 54 | schema=schema, 55 | create_schema=create_schema, 56 | update_schema=update_schema, 57 | prefix=prefix or db_model.__tablename__, 58 | tags=tags, 59 | paginate=paginate, 60 | get_all_route=get_all_route, 61 | get_one_route=get_one_route, 62 | create_route=create_route, 63 | update_route=update_route, 64 | delete_one_route=delete_one_route, 65 | delete_all_route=delete_all_route, 66 | **kwargs 67 | ) 68 | 69 | def _get_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 70 | async def route( 71 | pagination: PAGINATION = self.pagination, 72 | ) -> List[Model]: 73 | skip, limit = pagination.get("skip"), pagination.get("limit") 74 | 75 | db_models: List[Model] = ( 76 | await self.db_model.query.limit(limit).offset(skip).gino.all() 77 | ) 78 | return db_models 79 | 80 | return route 81 | 82 | def _get_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 83 | async def route(item_id: self._pk_type) -> Model: # type: ignore 84 | model: Model = await self.db_model.get(item_id) 85 | 86 | if model: 87 | return model 88 | else: 89 | raise NOT_FOUND 90 | 91 | return route 92 | 93 | def _create(self, *args: Any, **kwargs: Any) -> CALLABLE: 94 | async def route( 95 | model: self.create_schema, # type: ignore 96 | ) -> Model: 97 | try: 98 | async with self.db.transaction(): 99 | db_model: Model = await self.db_model.create(**model.dict()) 100 | return db_model 101 | except (IntegrityError, UniqueViolationError): 102 | raise HTTPException(422, "Key already exists") from None 103 | 104 | return route 105 | 106 | def _update(self, *args: Any, **kwargs: Any) -> CALLABLE: 107 | async def route( 108 | item_id: self._pk_type, # type: ignore 109 | model: self.update_schema, # type: ignore 110 | ) -> Model: 111 | try: 112 | db_model: Model = await self._get_one()(item_id) 113 | async with self.db.transaction(): 114 | model = model.dict(exclude={self._pk}) 115 | await db_model.update(**model).apply() 116 | 117 | return db_model 118 | except (IntegrityError, UniqueViolationError) as e: 119 | self._raise(e) 120 | 121 | return route 122 | 123 | def _delete_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 124 | async def route() -> List[Model]: 125 | await self.db_model.delete.gino.status() 126 | return await self._get_all()(pagination={"skip": 0, "limit": None}) 127 | 128 | return route 129 | 130 | def _delete_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 131 | async def route(item_id: self._pk_type) -> Model: # type: ignore 132 | db_model: Model = await self._get_one()(item_id) 133 | await db_model.delete() 134 | 135 | return db_model 136 | 137 | return route 138 | -------------------------------------------------------------------------------- /fastapi_crudrouter/core/mem.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, List, Type, cast, Optional, Union 2 | 3 | from . import CRUDGenerator, NOT_FOUND 4 | from ._types import DEPENDENCIES, PAGINATION, PYDANTIC_SCHEMA as SCHEMA 5 | 6 | CALLABLE = Callable[..., SCHEMA] 7 | CALLABLE_LIST = Callable[..., List[SCHEMA]] 8 | 9 | 10 | class MemoryCRUDRouter(CRUDGenerator[SCHEMA]): 11 | def __init__( 12 | self, 13 | schema: Type[SCHEMA], 14 | create_schema: Optional[Type[SCHEMA]] = None, 15 | update_schema: Optional[Type[SCHEMA]] = None, 16 | prefix: Optional[str] = None, 17 | tags: Optional[List[str]] = None, 18 | paginate: Optional[int] = None, 19 | get_all_route: Union[bool, DEPENDENCIES] = True, 20 | get_one_route: Union[bool, DEPENDENCIES] = True, 21 | create_route: Union[bool, DEPENDENCIES] = True, 22 | update_route: Union[bool, DEPENDENCIES] = True, 23 | delete_one_route: Union[bool, DEPENDENCIES] = True, 24 | delete_all_route: Union[bool, DEPENDENCIES] = True, 25 | **kwargs: Any 26 | ) -> None: 27 | super().__init__( 28 | schema=schema, 29 | create_schema=create_schema, 30 | update_schema=update_schema, 31 | prefix=prefix, 32 | tags=tags, 33 | paginate=paginate, 34 | get_all_route=get_all_route, 35 | get_one_route=get_one_route, 36 | create_route=create_route, 37 | update_route=update_route, 38 | delete_one_route=delete_one_route, 39 | delete_all_route=delete_all_route, 40 | **kwargs 41 | ) 42 | 43 | self.models: List[SCHEMA] = [] 44 | self._id = 1 45 | 46 | def _get_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 47 | def route(pagination: PAGINATION = self.pagination) -> List[SCHEMA]: 48 | skip, limit = pagination.get("skip"), pagination.get("limit") 49 | skip = cast(int, skip) 50 | 51 | return ( 52 | self.models[skip:] 53 | if limit is None 54 | else self.models[skip : skip + limit] 55 | ) 56 | 57 | return route 58 | 59 | def _get_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 60 | def route(item_id: int) -> SCHEMA: 61 | for model in self.models: 62 | if model.id == item_id: # type: ignore 63 | return model 64 | 65 | raise NOT_FOUND 66 | 67 | return route 68 | 69 | def _create(self, *args: Any, **kwargs: Any) -> CALLABLE: 70 | def route(model: self.create_schema) -> SCHEMA: # type: ignore 71 | model_dict = model.dict() 72 | model_dict["id"] = self._get_next_id() 73 | ready_model = self.schema(**model_dict) 74 | self.models.append(ready_model) 75 | return ready_model 76 | 77 | return route 78 | 79 | def _update(self, *args: Any, **kwargs: Any) -> CALLABLE: 80 | def route(item_id: int, model: self.update_schema) -> SCHEMA: # type: ignore 81 | for ind, model_ in enumerate(self.models): 82 | if model_.id == item_id: # type: ignore 83 | self.models[ind] = self.schema( 84 | **model.dict(), id=model_.id # type: ignore 85 | ) 86 | return self.models[ind] 87 | 88 | raise NOT_FOUND 89 | 90 | return route 91 | 92 | def _delete_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 93 | def route() -> List[SCHEMA]: 94 | self.models = [] 95 | return self.models 96 | 97 | return route 98 | 99 | def _delete_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 100 | def route(item_id: int) -> SCHEMA: 101 | for ind, model in enumerate(self.models): 102 | if model.id == item_id: # type: ignore 103 | del self.models[ind] 104 | return model 105 | 106 | raise NOT_FOUND 107 | 108 | return route 109 | 110 | def _get_next_id(self) -> int: 111 | id_ = self._id 112 | self._id += 1 113 | 114 | return id_ 115 | -------------------------------------------------------------------------------- /fastapi_crudrouter/core/ormar.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Any, 3 | Callable, 4 | List, 5 | Optional, 6 | Type, 7 | cast, 8 | Coroutine, 9 | Union, 10 | ) 11 | 12 | from fastapi import HTTPException 13 | 14 | from . import CRUDGenerator, NOT_FOUND, _utils 15 | from ._types import DEPENDENCIES, PAGINATION 16 | 17 | try: 18 | from ormar import Model, NoMatch 19 | except ImportError: 20 | Model = None # type: ignore 21 | NoMatch = None # type: ignore 22 | ormar_installed = False 23 | else: 24 | ormar_installed = True 25 | 26 | CALLABLE = Callable[..., Coroutine[Any, Any, Model]] 27 | CALLABLE_LIST = Callable[..., Coroutine[Any, Any, List[Optional[Model]]]] 28 | 29 | 30 | class OrmarCRUDRouter(CRUDGenerator[Model]): 31 | def __init__( 32 | self, 33 | schema: Type[Model], 34 | create_schema: Optional[Type[Model]] = None, 35 | update_schema: Optional[Type[Model]] = None, 36 | prefix: Optional[str] = None, 37 | tags: Optional[List[str]] = None, 38 | paginate: Optional[int] = None, 39 | get_all_route: Union[bool, DEPENDENCIES] = True, 40 | get_one_route: Union[bool, DEPENDENCIES] = True, 41 | create_route: Union[bool, DEPENDENCIES] = True, 42 | update_route: Union[bool, DEPENDENCIES] = True, 43 | delete_one_route: Union[bool, DEPENDENCIES] = True, 44 | delete_all_route: Union[bool, DEPENDENCIES] = True, 45 | **kwargs: Any 46 | ) -> None: 47 | assert ormar_installed, "Ormar must be installed to use the OrmarCRUDRouter." 48 | 49 | self._pk: str = schema.Meta.pkname 50 | self._pk_type: type = _utils.get_pk_type(schema, self._pk) 51 | 52 | super().__init__( 53 | schema=schema, 54 | create_schema=create_schema or schema, 55 | update_schema=update_schema or schema, 56 | prefix=prefix or schema.Meta.tablename, 57 | tags=tags, 58 | paginate=paginate, 59 | get_all_route=get_all_route, 60 | get_one_route=get_one_route, 61 | create_route=create_route, 62 | update_route=update_route, 63 | delete_one_route=delete_one_route, 64 | delete_all_route=delete_all_route, 65 | **kwargs 66 | ) 67 | 68 | self._INTEGRITY_ERROR = self._get_integrity_error_type() 69 | 70 | def _get_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 71 | async def route( 72 | pagination: PAGINATION = self.pagination, 73 | ) -> List[Optional[Model]]: 74 | skip, limit = pagination.get("skip"), pagination.get("limit") 75 | query = self.schema.objects.offset(cast(int, skip)) 76 | if limit: 77 | query = query.limit(limit) 78 | return await query.all() # type: ignore 79 | 80 | return route 81 | 82 | def _get_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 83 | async def route(item_id: self._pk_type) -> Model: # type: ignore 84 | try: 85 | filter_ = {self._pk: item_id} 86 | model = await self.schema.objects.filter( 87 | _exclude=False, **filter_ 88 | ).first() 89 | except NoMatch: 90 | raise NOT_FOUND from None 91 | return model 92 | 93 | return route 94 | 95 | def _create(self, *args: Any, **kwargs: Any) -> CALLABLE: 96 | async def route(model: self.create_schema) -> Model: # type: ignore 97 | model_dict = model.dict() 98 | if self.schema.Meta.model_fields[self._pk].autoincrement: 99 | model_dict.pop(self._pk, None) 100 | try: 101 | return await self.schema.objects.create(**model_dict) 102 | except self._INTEGRITY_ERROR: 103 | raise HTTPException(422, "Key already exists") from None 104 | 105 | return route 106 | 107 | def _update(self, *args: Any, **kwargs: Any) -> CALLABLE: 108 | async def route( 109 | item_id: self._pk_type, # type: ignore 110 | model: self.update_schema, # type: ignore 111 | ) -> Model: 112 | filter_ = {self._pk: item_id} 113 | try: 114 | await self.schema.objects.filter(_exclude=False, **filter_).update( 115 | **model.dict(exclude_unset=True) 116 | ) 117 | except self._INTEGRITY_ERROR as e: 118 | self._raise(e) 119 | return await self._get_one()(item_id) 120 | 121 | return route 122 | 123 | def _delete_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 124 | async def route() -> List[Optional[Model]]: 125 | await self.schema.objects.delete(each=True) 126 | return await self._get_all()(pagination={"skip": 0, "limit": None}) 127 | 128 | return route 129 | 130 | def _delete_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 131 | async def route(item_id: self._pk_type) -> Model: # type: ignore 132 | model = await self._get_one()(item_id) 133 | await model.delete() 134 | return model 135 | 136 | return route 137 | 138 | def _get_integrity_error_type(self) -> Type[Exception]: 139 | """Imports the Integrity exception based on the used backend""" 140 | backend = self.schema.db_backend_name() 141 | 142 | try: 143 | if backend == "sqlite": 144 | from sqlite3 import IntegrityError 145 | elif backend == "postgresql": 146 | from asyncpg import ( # type: ignore 147 | IntegrityConstraintViolationError as IntegrityError, 148 | ) 149 | else: 150 | from pymysql import IntegrityError # type: ignore 151 | return IntegrityError 152 | except ImportError: 153 | return Exception 154 | -------------------------------------------------------------------------------- /fastapi_crudrouter/core/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, List, Type, Generator, Optional, Union 2 | 3 | from fastapi import Depends, HTTPException 4 | 5 | from . import CRUDGenerator, NOT_FOUND, _utils 6 | from ._types import DEPENDENCIES, PAGINATION, PYDANTIC_SCHEMA as SCHEMA 7 | 8 | try: 9 | from sqlalchemy.orm import Session 10 | from sqlalchemy.ext.declarative import DeclarativeMeta as Model 11 | from sqlalchemy.exc import IntegrityError 12 | except ImportError: 13 | Model = None 14 | Session = None 15 | IntegrityError = None 16 | sqlalchemy_installed = False 17 | else: 18 | sqlalchemy_installed = True 19 | Session = Callable[..., Generator[Session, Any, None]] 20 | 21 | CALLABLE = Callable[..., Model] 22 | CALLABLE_LIST = Callable[..., List[Model]] 23 | 24 | 25 | class SQLAlchemyCRUDRouter(CRUDGenerator[SCHEMA]): 26 | def __init__( 27 | self, 28 | schema: Type[SCHEMA], 29 | db_model: Model, 30 | db: "Session", 31 | create_schema: Optional[Type[SCHEMA]] = None, 32 | update_schema: Optional[Type[SCHEMA]] = None, 33 | prefix: Optional[str] = None, 34 | tags: Optional[List[str]] = None, 35 | paginate: Optional[int] = None, 36 | get_all_route: Union[bool, DEPENDENCIES] = True, 37 | get_one_route: Union[bool, DEPENDENCIES] = True, 38 | create_route: Union[bool, DEPENDENCIES] = True, 39 | update_route: Union[bool, DEPENDENCIES] = True, 40 | delete_one_route: Union[bool, DEPENDENCIES] = True, 41 | delete_all_route: Union[bool, DEPENDENCIES] = True, 42 | **kwargs: Any 43 | ) -> None: 44 | assert ( 45 | sqlalchemy_installed 46 | ), "SQLAlchemy must be installed to use the SQLAlchemyCRUDRouter." 47 | 48 | self.db_model = db_model 49 | self.db_func = db 50 | self._pk: str = db_model.__table__.primary_key.columns.keys()[0] 51 | self._pk_type: type = _utils.get_pk_type(schema, self._pk) 52 | 53 | super().__init__( 54 | schema=schema, 55 | create_schema=create_schema, 56 | update_schema=update_schema, 57 | prefix=prefix or db_model.__tablename__, 58 | tags=tags, 59 | paginate=paginate, 60 | get_all_route=get_all_route, 61 | get_one_route=get_one_route, 62 | create_route=create_route, 63 | update_route=update_route, 64 | delete_one_route=delete_one_route, 65 | delete_all_route=delete_all_route, 66 | **kwargs 67 | ) 68 | 69 | def _get_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 70 | def route( 71 | db: Session = Depends(self.db_func), 72 | pagination: PAGINATION = self.pagination, 73 | ) -> List[Model]: 74 | skip, limit = pagination.get("skip"), pagination.get("limit") 75 | 76 | db_models: List[Model] = ( 77 | db.query(self.db_model) 78 | .order_by(getattr(self.db_model, self._pk)) 79 | .limit(limit) 80 | .offset(skip) 81 | .all() 82 | ) 83 | return db_models 84 | 85 | return route 86 | 87 | def _get_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 88 | def route( 89 | item_id: self._pk_type, db: Session = Depends(self.db_func) # type: ignore 90 | ) -> Model: 91 | model: Model = db.query(self.db_model).get(item_id) 92 | 93 | if model: 94 | return model 95 | else: 96 | raise NOT_FOUND from None 97 | 98 | return route 99 | 100 | def _create(self, *args: Any, **kwargs: Any) -> CALLABLE: 101 | def route( 102 | model: self.create_schema, # type: ignore 103 | db: Session = Depends(self.db_func), 104 | ) -> Model: 105 | try: 106 | db_model: Model = self.db_model(**model.dict()) 107 | db.add(db_model) 108 | db.commit() 109 | db.refresh(db_model) 110 | return db_model 111 | except IntegrityError: 112 | db.rollback() 113 | raise HTTPException(422, "Key already exists") from None 114 | 115 | return route 116 | 117 | def _update(self, *args: Any, **kwargs: Any) -> CALLABLE: 118 | def route( 119 | item_id: self._pk_type, # type: ignore 120 | model: self.update_schema, # type: ignore 121 | db: Session = Depends(self.db_func), 122 | ) -> Model: 123 | try: 124 | db_model: Model = self._get_one()(item_id, db) 125 | 126 | for key, value in model.dict(exclude={self._pk}).items(): 127 | if hasattr(db_model, key): 128 | setattr(db_model, key, value) 129 | 130 | db.commit() 131 | db.refresh(db_model) 132 | 133 | return db_model 134 | except IntegrityError as e: 135 | db.rollback() 136 | self._raise(e) 137 | 138 | return route 139 | 140 | def _delete_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 141 | def route(db: Session = Depends(self.db_func)) -> List[Model]: 142 | db.query(self.db_model).delete() 143 | db.commit() 144 | 145 | return self._get_all()(db=db, pagination={"skip": 0, "limit": None}) 146 | 147 | return route 148 | 149 | def _delete_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 150 | def route( 151 | item_id: self._pk_type, db: Session = Depends(self.db_func) # type: ignore 152 | ) -> Model: 153 | db_model: Model = self._get_one()(item_id, db) 154 | db.delete(db_model) 155 | db.commit() 156 | 157 | return db_model 158 | 159 | return route 160 | -------------------------------------------------------------------------------- /fastapi_crudrouter/core/tortoise.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, List, Type, cast, Coroutine, Optional, Union 2 | 3 | from . import CRUDGenerator, NOT_FOUND 4 | from ._types import DEPENDENCIES, PAGINATION, PYDANTIC_SCHEMA as SCHEMA 5 | 6 | try: 7 | from tortoise.models import Model 8 | except ImportError: 9 | Model = None # type: ignore 10 | tortoise_installed = False 11 | else: 12 | tortoise_installed = True 13 | 14 | 15 | CALLABLE = Callable[..., Coroutine[Any, Any, Model]] 16 | CALLABLE_LIST = Callable[..., Coroutine[Any, Any, List[Model]]] 17 | 18 | 19 | class TortoiseCRUDRouter(CRUDGenerator[SCHEMA]): 20 | def __init__( 21 | self, 22 | schema: Type[SCHEMA], 23 | db_model: Type[Model], 24 | create_schema: Optional[Type[SCHEMA]] = None, 25 | update_schema: Optional[Type[SCHEMA]] = None, 26 | prefix: Optional[str] = None, 27 | tags: Optional[List[str]] = None, 28 | paginate: Optional[int] = None, 29 | get_all_route: Union[bool, DEPENDENCIES] = True, 30 | get_one_route: Union[bool, DEPENDENCIES] = True, 31 | create_route: Union[bool, DEPENDENCIES] = True, 32 | update_route: Union[bool, DEPENDENCIES] = True, 33 | delete_one_route: Union[bool, DEPENDENCIES] = True, 34 | delete_all_route: Union[bool, DEPENDENCIES] = True, 35 | **kwargs: Any 36 | ) -> None: 37 | assert ( 38 | tortoise_installed 39 | ), "Tortoise ORM must be installed to use the TortoiseCRUDRouter." 40 | 41 | self.db_model = db_model 42 | self._pk: str = db_model.describe()["pk_field"]["db_column"] 43 | 44 | super().__init__( 45 | schema=schema, 46 | create_schema=create_schema, 47 | update_schema=update_schema, 48 | prefix=prefix or db_model.describe()["name"].replace("None.", ""), 49 | tags=tags, 50 | paginate=paginate, 51 | get_all_route=get_all_route, 52 | get_one_route=get_one_route, 53 | create_route=create_route, 54 | update_route=update_route, 55 | delete_one_route=delete_one_route, 56 | delete_all_route=delete_all_route, 57 | **kwargs 58 | ) 59 | 60 | def _get_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 61 | async def route(pagination: PAGINATION = self.pagination) -> List[Model]: 62 | skip, limit = pagination.get("skip"), pagination.get("limit") 63 | query = self.db_model.all().offset(cast(int, skip)) 64 | if limit: 65 | query = query.limit(limit) 66 | return await query 67 | 68 | return route 69 | 70 | def _get_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 71 | async def route(item_id: int) -> Model: 72 | model = await self.db_model.filter(id=item_id).first() 73 | 74 | if model: 75 | return model 76 | else: 77 | raise NOT_FOUND 78 | 79 | return route 80 | 81 | def _create(self, *args: Any, **kwargs: Any) -> CALLABLE: 82 | async def route(model: self.create_schema) -> Model: # type: ignore 83 | db_model = self.db_model(**model.dict()) 84 | await db_model.save() 85 | 86 | return db_model 87 | 88 | return route 89 | 90 | def _update(self, *args: Any, **kwargs: Any) -> CALLABLE: 91 | async def route( 92 | item_id: int, model: self.update_schema # type: ignore 93 | ) -> Model: 94 | await self.db_model.filter(id=item_id).update( 95 | **model.dict(exclude_unset=True) 96 | ) 97 | return await self._get_one()(item_id) 98 | 99 | return route 100 | 101 | def _delete_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 102 | async def route() -> List[Model]: 103 | await self.db_model.all().delete() 104 | return await self._get_all()(pagination={"skip": 0, "limit": None}) 105 | 106 | return route 107 | 108 | def _delete_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 109 | async def route(item_id: int) -> Model: 110 | model: Model = await self._get_one()(item_id) 111 | await self.db_model.filter(id=item_id).delete() 112 | 113 | return model 114 | 115 | return route 116 | -------------------------------------------------------------------------------- /fastapi_crudrouter/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/9b829865d85113a3f16f94c029502a9a584d47bb/fastapi_crudrouter/py.typed -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.62.0 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | select = C,E,F,W,B,B9 4 | ignore = B008, E203, W503, CFQ001, CFQ002, ECE001 5 | import-order-style = pycharm 6 | 7 | [mypy] 8 | disallow_any_generics = True 9 | disallow_subclassing_any = True 10 | disallow_untyped_calls = True 11 | disallow_untyped_defs = True 12 | disallow_incomplete_defs = True 13 | check_untyped_defs = True 14 | disallow_untyped_decorators = True 15 | no_implicit_optional = True 16 | warn_redundant_casts = True 17 | warn_unused_ignores = True 18 | warn_return_any = True 19 | implicit_reexport = False 20 | strict_equality = True 21 | 22 | [mypy-tests.*] 23 | ignore_errors = True 24 | 25 | [mypy-venv.*] 26 | ignore_errors = True 27 | 28 | [mypy-sqlalchemy.*] 29 | ignore_missing_imports = True 30 | 31 | [mypy-sqlalchemy_utils.*] 32 | ignore_missing_imports = True 33 | 34 | [mypy-uvicorn.*] 35 | ignore_missing_imports = True 36 | 37 | [mypy-gino.*] 38 | ignore_missing_imports = True 39 | 40 | [mypy-asyncpg.*] 41 | ignore_missing_imports = True 42 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from distutils.util import convert_path 3 | 4 | 5 | def get_version(): 6 | ver_path = convert_path("fastapi_crudrouter/_version.py") 7 | with open(ver_path) as ver_file: 8 | main_ns = {} 9 | exec(ver_file.read(), main_ns) 10 | return main_ns["__version__"] 11 | 12 | 13 | setup( 14 | name="fastapi-crudrouter", 15 | version=get_version(), 16 | author="Adam Watkins", 17 | author_email="hello@awtkns.com", 18 | packages=find_packages(exclude=("tests.*", "tests")), 19 | url="https://github.com/awtkns/fastapi-crudrouter", 20 | documentation="https://fastapi-crudrouter.awtkns.com/", 21 | license="MIT", 22 | description="A dynamic FastAPI router that automatically creates CRUD routes for your models", 23 | long_description=open("README.md").read(), 24 | long_description_content_type="text/markdown", 25 | install_requires=["fastapi"], 26 | python_requires=">=3.7", 27 | keywords=["fastapi", "crud", "restful", "routing", "generator", "crudrouter"], 28 | classifiers=[ 29 | "Operating System :: OS Independent", 30 | "Programming Language :: Python :: 3", 31 | "Programming Language :: Python", 32 | "Topic :: Internet", 33 | "Topic :: Software Development :: Libraries :: Application Frameworks", 34 | "Topic :: Software Development :: Libraries :: Python Modules", 35 | "Topic :: Software Development :: Libraries", 36 | "Topic :: Software Development :: Code Generators", 37 | "Topic :: Software Development", 38 | "Typing :: Typed", 39 | "Development Status :: 4 - Beta", 40 | "Environment :: Web Environment", 41 | "Framework :: AsyncIO", 42 | "Intended Audience :: Developers", 43 | "Intended Audience :: Information Technology", 44 | "Intended Audience :: System Administrators", 45 | "License :: OSI Approved :: MIT License", 46 | "Programming Language :: Python :: 3 :: Only", 47 | "Programming Language :: Python :: 3.7", 48 | "Programming Language :: Python :: 3.8", 49 | "Programming Language :: Python :: 3.9", 50 | "Programming Language :: Python :: 3.10", 51 | "Programming Language :: Python :: 3.11", 52 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 53 | "Topic :: Internet :: WWW/HTTP", 54 | ], 55 | ) 56 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from .conf import config 4 | 5 | PAGINATION_SIZE = 10 6 | CUSTOM_TAGS = ["Tag1", "Tag2"] 7 | 8 | 9 | class ORMModel(BaseModel): 10 | id: int 11 | 12 | class Config: 13 | orm_mode = True 14 | 15 | 16 | class PotatoCreate(BaseModel): 17 | thickness: float 18 | mass: float 19 | color: str 20 | type: str 21 | 22 | 23 | class Potato(PotatoCreate, ORMModel): 24 | pass 25 | 26 | 27 | class CustomPotato(PotatoCreate): 28 | potato_id: int 29 | 30 | class Config: 31 | orm_mode = True 32 | 33 | 34 | class CarrotCreate(BaseModel): 35 | length: float 36 | color: str = "Orange" 37 | 38 | 39 | class CarrotUpdate(BaseModel): 40 | length: float 41 | 42 | 43 | class Carrot(CarrotCreate, ORMModel): 44 | pass 45 | 46 | 47 | class PotatoType(BaseModel): 48 | name: str 49 | origin: str 50 | 51 | class Config: 52 | orm_mode = True 53 | -------------------------------------------------------------------------------- /tests/conf/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import BaseConfig 2 | 3 | config = BaseConfig() 4 | -------------------------------------------------------------------------------- /tests/conf/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | 5 | ENV_FILE_PATH = pathlib.Path(__file__).parent / "dev.env" 6 | assert ENV_FILE_PATH.exists() 7 | 8 | 9 | class BaseConfig: 10 | POSTGRES_HOST = "" 11 | POSTGRES_USER = "" 12 | POSTGRES_PASSWORD = "" 13 | POSTGRES_DB = "" 14 | POSTGRES_PORT = "" 15 | 16 | MSSQL_PORT = "" 17 | SA_PASSWORD = "" 18 | 19 | def __init__(self): 20 | self._apply_dot_env() 21 | self._apply_env_vars() 22 | self.POSTGRES_URI = f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" 23 | self.MSSQL_URI = f"mssql+pyodbc://sa:{self.SA_PASSWORD}@{self.POSTGRES_HOST}:{self.MSSQL_PORT}/test?driver=SQL+Server" 24 | 25 | def _apply_dot_env(self): 26 | with open(ENV_FILE_PATH) as fp: 27 | for line in fp.readlines(): 28 | line = line.strip(" \n") 29 | 30 | if line and not line.startswith("#"): 31 | k, v = line.split("=", 1) 32 | 33 | if hasattr(self, k) and not getattr(self, k): 34 | setattr(self, k, v) 35 | 36 | def _apply_env_vars(self): 37 | for k, v in os.environ.items(): 38 | if hasattr(self, k): 39 | setattr(self, k, v) 40 | -------------------------------------------------------------------------------- /tests/conf/dev.docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | db: 5 | image: postgres 6 | restart: always 7 | env_file: 8 | - dev.env 9 | ports: 10 | - 5432:5432 11 | # mssql: 12 | # image: mcr.microsoft.com/mssql/server:2019-latest 13 | # command: /bin/sh -c "(/opt/mssql/bin/sqlservr &) && sleep 10s && /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Password1! -Q 'CREATE DATABASE test' && sleep infinity" 14 | # restart: always 15 | # env_file: 16 | # - dev.env 17 | # ports: 18 | # - 1433:1433 -------------------------------------------------------------------------------- /tests/conf/dev.env: -------------------------------------------------------------------------------- 1 | POSTGRES_HOST=localhost 2 | POSTGRES_DB=test 3 | POSTGRES_USER=postgres 4 | POSTGRES_PASSWORD=password 5 | POSTGRES_PORT=5432 6 | 7 | ACCEPT_EULA=Y 8 | SA_PASSWORD=Password1! 9 | MSSQL_PORT=1433 10 | MSSQL_PID=Express -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from .implementations import * 5 | 6 | 7 | def yield_test_client(app, impl): 8 | if impl.__name__ == "tortoise_implementation": 9 | from tortoise.contrib.test import initializer, finalizer 10 | 11 | initializer(["tests.implementations.tortoise_"]) 12 | with TestClient(app) as c: 13 | yield c 14 | finalizer() 15 | 16 | else: 17 | with TestClient(app) as c: 18 | yield c 19 | 20 | 21 | def label_func(*args): 22 | func, dsn = args[0] 23 | dsn = dsn.split(":")[0].split("+")[0] 24 | return f"{func.__name__}-{dsn}" 25 | 26 | 27 | @pytest.fixture(params=implementations, ids=label_func, scope="class") 28 | def client(request): 29 | impl, dsn = request.param 30 | 31 | app, router, settings = impl(db_uri=dsn) 32 | [app.include_router(router(**kwargs)) for kwargs in settings] 33 | yield from yield_test_client(app, impl) 34 | 35 | 36 | @pytest.fixture( 37 | params=[ 38 | sqlalchemy_implementation_custom_ids, 39 | databases_implementation_custom_ids, 40 | ormar_implementation_custom_ids, 41 | gino_implementation_custom_ids, 42 | ] 43 | ) 44 | def custom_id_client(request): 45 | yield from yield_test_client(request.param(), request.param) 46 | 47 | 48 | @pytest.fixture( 49 | params=[ 50 | sqlalchemy_implementation_string_pk, 51 | databases_implementation_string_pk, 52 | ormar_implementation_string_pk, 53 | gino_implementation_string_pk, 54 | ], 55 | scope="function", 56 | ) 57 | def string_pk_client(request): 58 | yield from yield_test_client(request.param(), request.param) 59 | 60 | 61 | @pytest.fixture( 62 | params=[ 63 | sqlalchemy_implementation_integrity_errors, 64 | ormar_implementation_integrity_errors, 65 | gino_implementation_integrity_errors, 66 | ], 67 | scope="function", 68 | ) 69 | def integrity_errors_client(request): 70 | yield from yield_test_client(request.param(), request.param) 71 | -------------------------------------------------------------------------------- /tests/dev.requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | httpx 3 | 4 | # Backends 5 | ormar 6 | tortoise-orm==0.18.1 7 | databases 8 | aiosqlite 9 | sqlalchemy<1.4.0 10 | sqlalchemy_utils==0.36.8 11 | gino-starlette==0.1.1 12 | asyncpg 13 | 14 | # Testing 15 | pytest 16 | pytest-virtualenv 17 | requests 18 | asynctest 19 | psycopg2 20 | pyodbc 21 | 22 | # Linting 23 | flake8 24 | flake8-black 25 | flake8-bugbear 26 | flake8-import-order 27 | flake8-bandit 28 | flake8-annotations 29 | flake8-builtins 30 | flake8-variables-names 31 | flake8-functions 32 | flake8-expression-complexity 33 | 34 | # Typing 35 | mypy==0.910 36 | -------------------------------------------------------------------------------- /tests/implementations/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from .databases_ import ( 4 | databases_implementation, 5 | databases_implementation_custom_ids, 6 | databases_implementation_string_pk, 7 | ) 8 | from .gino_ import ( 9 | gino_implementation, 10 | gino_implementation_custom_ids, 11 | gino_implementation_integrity_errors, 12 | gino_implementation_string_pk, 13 | ) 14 | from .memory import memory_implementation 15 | from .ormar_ import ( 16 | ormar_implementation, 17 | ormar_implementation_custom_ids, 18 | ormar_implementation_integrity_errors, 19 | ormar_implementation_string_pk, 20 | ) 21 | from .sqlalchemy_ import ( 22 | sqlalchemy_implementation, 23 | sqlalchemy_implementation_custom_ids, 24 | sqlalchemy_implementation_integrity_errors, 25 | sqlalchemy_implementation_string_pk, 26 | DSN_LIST, 27 | ) 28 | from .tortoise_ import tortoise_implementation 29 | 30 | implementations = [ 31 | (memory_implementation, ""), 32 | (ormar_implementation, ""), 33 | (gino_implementation, ""), 34 | ] 35 | 36 | implementations.extend([(sqlalchemy_implementation, dsn) for dsn in DSN_LIST]) 37 | implementations.extend([(databases_implementation, dsn) for dsn in DSN_LIST]) 38 | 39 | 40 | if sys.version_info >= (3, 8): 41 | implementations.append((tortoise_implementation, "")) 42 | -------------------------------------------------------------------------------- /tests/implementations/databases_.py: -------------------------------------------------------------------------------- 1 | import databases 2 | from fastapi import FastAPI 3 | from sqlalchemy import Column, Float, Integer, MetaData, String, Table, create_engine 4 | from sqlalchemy_utils import create_database, database_exists, drop_database 5 | 6 | from fastapi_crudrouter import DatabasesCRUDRouter 7 | from tests import ( 8 | Carrot, 9 | CarrotCreate, 10 | CarrotUpdate, 11 | CustomPotato, 12 | PAGINATION_SIZE, 13 | Potato, 14 | PotatoType, 15 | CUSTOM_TAGS, 16 | config, 17 | ) 18 | 19 | DSN_LIST = [ 20 | "sqlite:///./test.db?check_same_thread=false", 21 | # config.MSSQL_URI, 22 | config.POSTGRES_URI, 23 | ] 24 | 25 | 26 | def _setup_database(db_uri: str = DSN_LIST[0]): 27 | if database_exists(db_uri): 28 | drop_database(db_uri) 29 | 30 | create_database(db_uri) 31 | database = databases.Database(db_uri) 32 | engine = create_engine(db_uri) 33 | 34 | return engine, database 35 | 36 | 37 | def databases_implementation(db_uri: str): 38 | engine, database = _setup_database(db_uri) 39 | 40 | metadata = MetaData() 41 | potatoes = Table( 42 | "potatoes", 43 | metadata, 44 | Column("id", Integer, primary_key=True), 45 | Column("thickness", Float), 46 | Column("mass", Float), 47 | Column("color", String), 48 | Column("type", String), 49 | ) 50 | carrots = Table( 51 | "carrots", 52 | metadata, 53 | Column("id", Integer, primary_key=True), 54 | Column("length", Float), 55 | Column("color", String), 56 | ) 57 | metadata.create_all(bind=engine) 58 | 59 | app = FastAPI() 60 | 61 | @app.on_event("startup") 62 | async def startup(): 63 | await database.connect() 64 | 65 | @app.on_event("shutdown") 66 | async def shutdown(): 67 | await database.disconnect() 68 | 69 | router_settings = [ 70 | dict( 71 | database=database, 72 | table=potatoes, 73 | schema=Potato, 74 | prefix="potato", 75 | paginate=PAGINATION_SIZE, 76 | ), 77 | dict( 78 | database=database, 79 | table=carrots, 80 | schema=Carrot, 81 | create_schema=CarrotCreate, 82 | update_schema=CarrotUpdate, 83 | prefix="carrot", 84 | tags=CUSTOM_TAGS, 85 | ), 86 | ] 87 | 88 | return app, DatabasesCRUDRouter, router_settings 89 | 90 | 91 | def databases_implementation_custom_ids(): 92 | engine, database = _setup_database() 93 | 94 | metadata = MetaData() 95 | potatoes = Table( 96 | "potatoes", 97 | metadata, 98 | Column("potato_id", Integer, primary_key=True), 99 | Column("thickness", Float), 100 | Column("mass", Float), 101 | Column("color", String), 102 | Column("type", String), 103 | ) 104 | 105 | metadata.create_all(bind=engine) 106 | 107 | app = FastAPI() 108 | 109 | @app.on_event("startup") 110 | async def startup(): 111 | await database.connect() 112 | 113 | @app.on_event("shutdown") 114 | async def shutdown(): 115 | await database.disconnect() 116 | 117 | potato_router = DatabasesCRUDRouter( 118 | database=database, table=potatoes, schema=CustomPotato 119 | ) 120 | app.include_router(potato_router) 121 | 122 | return app 123 | 124 | 125 | def databases_implementation_string_pk(): 126 | engine, database = _setup_database() 127 | 128 | metadata = MetaData() 129 | potato_types = Table( 130 | "potato_type", 131 | metadata, 132 | Column("name", String, primary_key=True), 133 | Column("origin", String), 134 | ) 135 | 136 | metadata.create_all(bind=engine) 137 | 138 | app = FastAPI() 139 | 140 | @app.on_event("startup") 141 | async def startup(): 142 | await database.connect() 143 | 144 | @app.on_event("shutdown") 145 | async def shutdown(): 146 | await database.disconnect() 147 | 148 | potato_router = DatabasesCRUDRouter( 149 | database=database, 150 | table=potato_types, 151 | schema=PotatoType, 152 | create_schema=PotatoType, 153 | ) 154 | app.include_router(potato_router) 155 | 156 | return app 157 | -------------------------------------------------------------------------------- /tests/implementations/gino_.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from fastapi import FastAPI 3 | from fastapi_crudrouter import GinoCRUDRouter 4 | from gino.ext.starlette import Gino 5 | from sqlalchemy_utils import create_database, database_exists, drop_database 6 | from tests import ( 7 | CUSTOM_TAGS, 8 | PAGINATION_SIZE, 9 | Carrot, 10 | CarrotCreate, 11 | CarrotUpdate, 12 | CustomPotato, 13 | Potato, 14 | PotatoType, 15 | config, 16 | ) 17 | 18 | 19 | GINO_DATABASE_URL = config.POSTGRES_URI.replace("postgresql", "asyncpg") 20 | 21 | 22 | async def migrate(db): 23 | async with db.with_bind(GINO_DATABASE_URL): 24 | await db.gino.create_all() 25 | 26 | 27 | def _setup_base_app(): 28 | if database_exists(config.POSTGRES_URI): 29 | drop_database(config.POSTGRES_URI) 30 | 31 | create_database(config.POSTGRES_URI) 32 | 33 | app = FastAPI() 34 | db = Gino(dsn=GINO_DATABASE_URL) 35 | db.init_app(app) 36 | return db, app 37 | 38 | 39 | def gino_implementation(**kwargs): 40 | db, app = _setup_base_app() 41 | 42 | class PotatoModel(db.Model): 43 | __tablename__ = "potatoes" 44 | id = db.Column(db.Integer, primary_key=True, index=True) 45 | thickness = db.Column(db.Float) 46 | mass = db.Column(db.Float) 47 | color = db.Column(db.String) 48 | type = db.Column(db.String) 49 | 50 | class CarrotModel(db.Model): 51 | __tablename__ = "carrots" 52 | id = db.Column(db.Integer, primary_key=True, index=True) 53 | length = db.Column(db.Float) 54 | color = db.Column(db.String) 55 | 56 | asyncio.get_event_loop().run_until_complete(migrate(db)) 57 | 58 | router_settings = [ 59 | dict( 60 | schema=Potato, 61 | db_model=PotatoModel, 62 | db=db, 63 | prefix="potato", 64 | paginate=PAGINATION_SIZE, 65 | ), 66 | dict( 67 | schema=Carrot, 68 | db_model=CarrotModel, 69 | db=db, 70 | create_schema=CarrotCreate, 71 | update_schema=CarrotUpdate, 72 | prefix="carrot", 73 | tags=CUSTOM_TAGS, 74 | ), 75 | ] 76 | 77 | return app, GinoCRUDRouter, router_settings 78 | 79 | 80 | # noinspection DuplicatedCode 81 | def gino_implementation_custom_ids(): 82 | db, app = _setup_base_app() 83 | 84 | class PotatoModel(db.Model): 85 | __tablename__ = "potatoes" 86 | potato_id = db.Column(db.Integer, primary_key=True, index=True) 87 | thickness = db.Column(db.Float) 88 | mass = db.Column(db.Float) 89 | color = db.Column(db.String) 90 | type = db.Column(db.String) 91 | 92 | asyncio.get_event_loop().run_until_complete(migrate(db)) 93 | 94 | app.include_router(GinoCRUDRouter(schema=CustomPotato, db_model=PotatoModel, db=db)) 95 | 96 | return app 97 | 98 | 99 | def gino_implementation_string_pk(): 100 | db, app = _setup_base_app() 101 | 102 | class PotatoTypeModel(db.Model): 103 | __tablename__ = "potato_type" 104 | name = db.Column(db.String, primary_key=True, index=True) 105 | origin = db.Column(db.String) 106 | 107 | asyncio.get_event_loop().run_until_complete(migrate(db)) 108 | 109 | app.include_router( 110 | GinoCRUDRouter( 111 | schema=PotatoType, 112 | create_schema=PotatoType, 113 | db_model=PotatoTypeModel, 114 | db=db, 115 | prefix="potato_type", 116 | ) 117 | ) 118 | 119 | return app 120 | 121 | 122 | def gino_implementation_integrity_errors(): 123 | db, app = _setup_base_app() 124 | 125 | class PotatoModel(db.Model): 126 | __tablename__ = "potatoes" 127 | id = db.Column(db.Integer, primary_key=True, index=True) 128 | thickness = db.Column(db.Float) 129 | mass = db.Column(db.Float) 130 | color = db.Column(db.String, unique=True) 131 | type = db.Column(db.String) 132 | 133 | class CarrotModel(db.Model): 134 | __tablename__ = "carrots" 135 | id = db.Column(db.Integer, primary_key=True, index=True) 136 | length = db.Column(db.Float) 137 | color = db.Column(db.String) 138 | 139 | asyncio.get_event_loop().run_until_complete(migrate(db)) 140 | 141 | app.include_router( 142 | GinoCRUDRouter( 143 | schema=Potato, 144 | db_model=PotatoModel, 145 | db=db, 146 | create_schema=Potato, 147 | prefix="potatoes", 148 | ) 149 | ) 150 | app.include_router( 151 | GinoCRUDRouter( 152 | schema=Carrot, 153 | db_model=CarrotModel, 154 | db=db, 155 | update_schema=CarrotUpdate, 156 | prefix="carrots", 157 | ) 158 | ) 159 | 160 | return app 161 | -------------------------------------------------------------------------------- /tests/implementations/memory.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from fastapi_crudrouter import MemoryCRUDRouter 4 | from tests import Potato, Carrot, CarrotUpdate, PAGINATION_SIZE, CUSTOM_TAGS 5 | 6 | 7 | def memory_implementation(**kwargs): 8 | app = FastAPI() 9 | router_settings = [ 10 | dict(schema=Potato, paginate=PAGINATION_SIZE), 11 | dict(schema=Carrot, update_schema=CarrotUpdate, tags=CUSTOM_TAGS), 12 | ] 13 | 14 | return app, MemoryCRUDRouter, router_settings 15 | 16 | 17 | if __name__ == "__main__": 18 | import uvicorn 19 | 20 | uvicorn.run(memory_implementation(), port=5000) 21 | -------------------------------------------------------------------------------- /tests/implementations/ormar_.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import databases 4 | import ormar 5 | import pytest 6 | import sqlalchemy 7 | from fastapi import FastAPI 8 | 9 | from fastapi_crudrouter import OrmarCRUDRouter 10 | from tests import CarrotCreate, CarrotUpdate, PAGINATION_SIZE, CUSTOM_TAGS 11 | 12 | DATABASE_URL = "sqlite:///./test.db" 13 | database = databases.Database(DATABASE_URL) 14 | metadata = sqlalchemy.MetaData() 15 | 16 | 17 | @pytest.fixture(scope="function", autouse=True) 18 | async def cleanup(): 19 | async with database: 20 | await PotatoModel.objects.delete(each=True) 21 | await CarrotModel.objects.delete(each=True) 22 | 23 | 24 | class BaseMeta(ormar.ModelMeta): 25 | metadata = metadata 26 | database = database 27 | 28 | 29 | def _setup_database(): 30 | engine = sqlalchemy.create_engine(DATABASE_URL) 31 | metadata.drop_all(engine) 32 | metadata.create_all(engine) 33 | return engine, database 34 | 35 | 36 | class PotatoModel(ormar.Model): 37 | class Meta(BaseMeta): 38 | pass 39 | 40 | id = ormar.Integer(primary_key=True) 41 | thickness = ormar.Float() 42 | mass = ormar.Float() 43 | color = ormar.String(max_length=255) 44 | type = ormar.String(max_length=255) 45 | 46 | 47 | class CarrotModel(ormar.Model): 48 | class Meta(BaseMeta): 49 | pass 50 | 51 | id = ormar.Integer(primary_key=True) 52 | length = ormar.Float() 53 | color = ormar.String(max_length=255) 54 | 55 | 56 | class PotatoTypeModel(ormar.Model): 57 | class Meta(BaseMeta): 58 | tablename = "potato_type" 59 | 60 | name = ormar.String(primary_key=True, max_length=300) 61 | origin = ormar.String(max_length=300) 62 | 63 | 64 | class CustomPotatoModel(ormar.Model): 65 | class Meta(BaseMeta): 66 | tablename = "custom_potatoes" 67 | 68 | potato_id = ormar.Integer(primary_key=True) 69 | thickness = ormar.Float() 70 | mass = ormar.Float() 71 | color = ormar.String(max_length=255) 72 | type = ormar.String(max_length=255) 73 | 74 | 75 | class UniquePotatoModel(ormar.Model): 76 | class Meta(BaseMeta): 77 | pass 78 | 79 | id = ormar.Integer(primary_key=True) 80 | thickness = ormar.Float() 81 | mass = ormar.Float() 82 | color = ormar.String(max_length=255, unique=True) 83 | type = ormar.String(max_length=255) 84 | 85 | 86 | def get_app(): 87 | [ 88 | os.remove(f"./db.sqlite3{s}") 89 | for s in ["", "-wal", "-shm"] 90 | if os.path.exists(f"./db.sqlite3{s}") 91 | ] 92 | 93 | _setup_database() 94 | 95 | app = FastAPI() 96 | 97 | @app.on_event("startup") 98 | async def startup(): 99 | await database.connect() 100 | 101 | @app.on_event("shutdown") 102 | async def shutdown(): 103 | await database.disconnect() 104 | 105 | return app 106 | 107 | 108 | def ormar_implementation(**kwargs): 109 | app = get_app() 110 | 111 | router_settings = [ 112 | dict( 113 | schema=PotatoModel, 114 | prefix="potato", 115 | paginate=PAGINATION_SIZE, 116 | ), 117 | dict( 118 | schema=CarrotModel, 119 | update_schema=CarrotUpdate, 120 | prefix="carrot", 121 | tags=CUSTOM_TAGS, 122 | ), 123 | ] 124 | 125 | return ( 126 | app, 127 | OrmarCRUDRouter, 128 | router_settings, 129 | ) 130 | 131 | 132 | # noinspection DuplicatedCode 133 | def ormar_implementation_custom_ids(): 134 | app = get_app() 135 | 136 | app.include_router( 137 | OrmarCRUDRouter( 138 | schema=CustomPotatoModel, 139 | prefix="potatoes", 140 | paginate=PAGINATION_SIZE, 141 | ) 142 | ) 143 | 144 | return app 145 | 146 | 147 | def ormar_implementation_string_pk(): 148 | app = get_app() 149 | 150 | app.include_router( 151 | OrmarCRUDRouter( 152 | schema=PotatoTypeModel, 153 | prefix="potato_type", 154 | ) 155 | ) 156 | 157 | return app 158 | 159 | 160 | def ormar_implementation_integrity_errors(): 161 | app = get_app() 162 | 163 | app.include_router( 164 | OrmarCRUDRouter( 165 | schema=UniquePotatoModel, 166 | prefix="potatoes", 167 | paginate=PAGINATION_SIZE, 168 | ) 169 | ) 170 | app.include_router( 171 | OrmarCRUDRouter( 172 | schema=CarrotModel, 173 | create_schema=CarrotCreate, 174 | update_schema=CarrotUpdate, 175 | prefix="carrots", 176 | ) 177 | ) 178 | 179 | return app 180 | -------------------------------------------------------------------------------- /tests/implementations/sqlalchemy_.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from sqlalchemy import Column, Float, Integer, String 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.ext.declarative import declarative_base 5 | from sqlalchemy.orm import sessionmaker 6 | from sqlalchemy_utils import create_database, database_exists, drop_database 7 | 8 | from fastapi_crudrouter import SQLAlchemyCRUDRouter 9 | from tests import ( 10 | Carrot, 11 | CarrotCreate, 12 | CarrotUpdate, 13 | CustomPotato, 14 | PAGINATION_SIZE, 15 | Potato, 16 | PotatoType, 17 | CUSTOM_TAGS, 18 | config, 19 | ) 20 | 21 | DSN_LIST = [ 22 | "sqlite:///./test.db?check_same_thread=false", 23 | # config.MSSQL_URI, 24 | config.POSTGRES_URI, 25 | ] 26 | 27 | 28 | def _setup_base_app(db_uri: str = DSN_LIST[0]): 29 | if database_exists(db_uri): 30 | drop_database(db_uri) 31 | 32 | create_database(db_uri) 33 | 34 | app = FastAPI() 35 | 36 | engine = create_engine(db_uri) 37 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 38 | Base = declarative_base() 39 | 40 | def session(): 41 | session = SessionLocal() 42 | try: 43 | yield session 44 | session.commit() 45 | finally: 46 | session.close() 47 | 48 | return app, engine, Base, session 49 | 50 | 51 | def sqlalchemy_implementation(db_uri: str): 52 | app, engine, Base, session = _setup_base_app(db_uri) 53 | 54 | class PotatoModel(Base): 55 | __tablename__ = "potatoes" 56 | id = Column(Integer, primary_key=True, index=True) 57 | thickness = Column(Float) 58 | mass = Column(Float) 59 | color = Column(String) 60 | type = Column(String) 61 | 62 | class CarrotModel(Base): 63 | __tablename__ = "carrots" 64 | id = Column(Integer, primary_key=True, index=True) 65 | length = Column(Float) 66 | color = Column(String) 67 | 68 | Base.metadata.create_all(bind=engine) 69 | router_settings = [ 70 | dict( 71 | schema=Potato, 72 | db_model=PotatoModel, 73 | db=session, 74 | prefix="potato", 75 | paginate=PAGINATION_SIZE, 76 | ), 77 | dict( 78 | schema=Carrot, 79 | db_model=CarrotModel, 80 | db=session, 81 | create_schema=CarrotCreate, 82 | update_schema=CarrotUpdate, 83 | prefix="carrot", 84 | tags=CUSTOM_TAGS, 85 | ), 86 | ] 87 | 88 | return app, SQLAlchemyCRUDRouter, router_settings 89 | 90 | 91 | # noinspection DuplicatedCode 92 | def sqlalchemy_implementation_custom_ids(): 93 | app, engine, Base, session = _setup_base_app() 94 | 95 | class PotatoModel(Base): 96 | __tablename__ = "potatoes" 97 | potato_id = Column(Integer, primary_key=True, index=True) 98 | thickness = Column(Float) 99 | mass = Column(Float) 100 | color = Column(String) 101 | type = Column(String) 102 | 103 | Base.metadata.create_all(bind=engine) 104 | app.include_router( 105 | SQLAlchemyCRUDRouter(schema=CustomPotato, db_model=PotatoModel, db=session) 106 | ) 107 | 108 | return app 109 | 110 | 111 | def sqlalchemy_implementation_string_pk(): 112 | app, engine, Base, session = _setup_base_app() 113 | 114 | class PotatoTypeModel(Base): 115 | __tablename__ = "potato_type" 116 | name = Column(String, primary_key=True, index=True) 117 | origin = Column(String) 118 | 119 | Base.metadata.create_all(bind=engine) 120 | app.include_router( 121 | SQLAlchemyCRUDRouter( 122 | schema=PotatoType, 123 | create_schema=PotatoType, 124 | db_model=PotatoTypeModel, 125 | db=session, 126 | prefix="potato_type", 127 | ) 128 | ) 129 | 130 | return app 131 | 132 | 133 | def sqlalchemy_implementation_integrity_errors(): 134 | app, engine, Base, session = _setup_base_app() 135 | 136 | class PotatoModel(Base): 137 | __tablename__ = "potatoes" 138 | id = Column(Integer, primary_key=True, index=True) 139 | thickness = Column(Float) 140 | mass = Column(Float) 141 | color = Column(String, unique=True) 142 | type = Column(String) 143 | 144 | class CarrotModel(Base): 145 | __tablename__ = "carrots" 146 | id = Column(Integer, primary_key=True, index=True) 147 | length = Column(Float) 148 | color = Column(String) 149 | 150 | Base.metadata.create_all(bind=engine) 151 | app.include_router( 152 | SQLAlchemyCRUDRouter( 153 | schema=Potato, 154 | db_model=PotatoModel, 155 | db=session, 156 | create_schema=Potato, 157 | prefix="potatoes", 158 | ) 159 | ) 160 | app.include_router( 161 | SQLAlchemyCRUDRouter( 162 | schema=Carrot, 163 | db_model=CarrotModel, 164 | db=session, 165 | update_schema=CarrotUpdate, 166 | prefix="carrots", 167 | ) 168 | ) 169 | 170 | return app 171 | -------------------------------------------------------------------------------- /tests/implementations/tortoise_.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fastapi import FastAPI 4 | from tortoise import Model, Tortoise, fields 5 | 6 | from fastapi_crudrouter import TortoiseCRUDRouter 7 | from tests import ( 8 | Carrot, 9 | CarrotCreate, 10 | CarrotUpdate, 11 | PAGINATION_SIZE, 12 | Potato, 13 | CUSTOM_TAGS, 14 | ) 15 | 16 | 17 | class PotatoModel(Model): 18 | thickness = fields.FloatField() 19 | mass = fields.FloatField() 20 | color = fields.CharField(max_length=255) 21 | type = fields.CharField(max_length=255) 22 | 23 | 24 | class CarrotModel(Model): 25 | length = fields.FloatField() 26 | color = fields.CharField(max_length=255) 27 | 28 | 29 | TORTOISE_ORM = { 30 | "connections": {"default": "sqlite://db.sqlite3"}, 31 | "apps": { 32 | "models": { 33 | "models": ["tests.implementations.tortoise_"], 34 | "default_connection": "default", 35 | }, 36 | }, 37 | } 38 | 39 | 40 | async def on_shutdown(): 41 | await Tortoise.close_connections() 42 | 43 | 44 | def tortoise_implementation(**kwargs): 45 | [ 46 | os.remove(f"./db.sqlite3{s}") 47 | for s in ["", "-wal", "-shm"] 48 | if os.path.exists(f"./db.sqlite3{s}") 49 | ] 50 | 51 | app = FastAPI(on_shutdown=[on_shutdown]) 52 | 53 | Tortoise.init(config=TORTOISE_ORM) 54 | Tortoise.generate_schemas() 55 | 56 | router_settings = [ 57 | dict( 58 | schema=Potato, 59 | db_model=PotatoModel, 60 | prefix="potato", 61 | paginate=PAGINATION_SIZE, 62 | ), 63 | dict( 64 | schema=Carrot, 65 | db_model=CarrotModel, 66 | create_schema=CarrotCreate, 67 | update_schema=CarrotUpdate, 68 | prefix="carrot", 69 | tags=CUSTOM_TAGS, 70 | ), 71 | ] 72 | 73 | return app, TortoiseCRUDRouter, router_settings 74 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Type 3 | 4 | import pytest 5 | from fastapi import APIRouter, FastAPI 6 | 7 | from fastapi_crudrouter import ( 8 | GinoCRUDRouter, 9 | MemoryCRUDRouter, 10 | OrmarCRUDRouter, 11 | SQLAlchemyCRUDRouter, 12 | DatabasesCRUDRouter, 13 | ) 14 | 15 | # noinspection PyProtectedMember 16 | from fastapi_crudrouter.core._base import CRUDGenerator 17 | from tests import Potato 18 | 19 | 20 | @pytest.fixture( 21 | params=[ 22 | GinoCRUDRouter, 23 | SQLAlchemyCRUDRouter, 24 | MemoryCRUDRouter, 25 | OrmarCRUDRouter, 26 | GinoCRUDRouter, 27 | DatabasesCRUDRouter, 28 | ] 29 | ) 30 | def subclass(request) -> Type[CRUDGenerator]: 31 | return request.param 32 | 33 | 34 | def test_router_is_subclass_of_crud_generator(subclass): 35 | assert issubclass(subclass, CRUDGenerator) 36 | 37 | 38 | def test_router_is_subclass_of_api_router(subclass): 39 | assert issubclass(subclass, APIRouter) 40 | 41 | 42 | def test_base_class_is_abstract(): 43 | assert issubclass(CRUDGenerator, ABC) 44 | 45 | 46 | def test_raise_not_implemented(): 47 | app = FastAPI() 48 | 49 | def foo(*args, **kwargs): 50 | def bar(): 51 | pass 52 | 53 | return bar 54 | 55 | methods = CRUDGenerator.get_routes() 56 | 57 | for m in methods: 58 | with pytest.raises(TypeError): 59 | app.include_router(CRUDGenerator(schema=Potato)) 60 | 61 | setattr(CRUDGenerator, f"_{m}", foo) 62 | -------------------------------------------------------------------------------- /tests/test_custom_ids.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import test_router 4 | 5 | basic_potato = dict(potato_id=1, thickness=0.24, mass=1.2, color="Brown", type="Russet") 6 | 7 | PotatoUrl = "/potatoes" 8 | 9 | 10 | def test_get(custom_id_client): 11 | test_router.test_get(custom_id_client, PotatoUrl) 12 | 13 | 14 | def test_post(custom_id_client): 15 | test_router.test_post(custom_id_client, PotatoUrl, basic_potato) 16 | 17 | 18 | def test_get_one(custom_id_client): 19 | test_router.test_get_one( 20 | custom_id_client, PotatoUrl, basic_potato, id_key="potato_id" 21 | ) 22 | 23 | 24 | def test_update(custom_id_client): 25 | test_router.test_update( 26 | custom_id_client, PotatoUrl, basic_potato, id_key="potato_id" 27 | ) 28 | 29 | 30 | def test_delete_one(custom_id_client): 31 | test_router.test_delete_one( 32 | custom_id_client, PotatoUrl, basic_potato, id_key="potato_id" 33 | ) 34 | 35 | 36 | def test_delete_all(custom_id_client): 37 | test_router.test_delete_all(custom_id_client, PotatoUrl, basic_potato) 38 | 39 | 40 | @pytest.mark.parametrize("id_", [-1, 0, 4, "14"]) 41 | def test_not_found(custom_id_client, id_): 42 | test_router.test_not_found(custom_id_client, id_, PotatoUrl, basic_potato) 43 | -------------------------------------------------------------------------------- /tests/test_dependencies/test_disable.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_crudrouter.core import CRUDGenerator 4 | 5 | from tests.implementations import implementations 6 | from tests.conftest import yield_test_client, label_func 7 | from tests import test_router 8 | 9 | 10 | URLS = ["/potato", "/carrot"] 11 | AUTH = {"Authorization": "Bearer my_token"} 12 | KEY_WORDS = {f"{r}_route" for r in CRUDGenerator.get_routes()} 13 | DISABLE_KWARGS = {k: False for k in KEY_WORDS} 14 | 15 | 16 | @pytest.fixture(params=implementations, ids=label_func, scope="class") 17 | def client(request): 18 | impl, dsn = request.param 19 | 20 | app, router, settings = impl(db_uri=dsn) 21 | [app.include_router(router(**s, **DISABLE_KWARGS)) for s in settings] 22 | 23 | yield from yield_test_client(app, impl) 24 | 25 | 26 | @pytest.fixture(params=implementations, ids=label_func, scope="class") 27 | def delete_all_client(request): 28 | impl, dsn = request.param 29 | 30 | app, router, settings = impl(db_uri=dsn) 31 | [ 32 | app.include_router(router(**s, delete_all_route=False, update_route=False)) 33 | for s in settings 34 | ] 35 | 36 | yield from yield_test_client(app, impl) 37 | 38 | 39 | @pytest.mark.parametrize("url", URLS) 40 | def test_route_disable(client, url): 41 | assert client.get(url).status_code == 404 42 | assert client.get(url).status_code == 404 43 | assert client.post(url).status_code == 404 44 | 45 | for id_ in [-1, 1, 0, 14]: 46 | assert client.get(f"{url}/{id_}").status_code == 404 47 | assert client.put(f"{url}/{id_}").status_code == 404 48 | assert client.delete(f"{url}/{id_}").status_code == 404 49 | 50 | 51 | def test_route_disable_single(delete_all_client): 52 | url = "/potato" 53 | 54 | assert delete_all_client.delete(url).status_code == 405 55 | 56 | test_router.test_post(delete_all_client, url) 57 | assert delete_all_client.put(f"{url}/{1}").status_code == 405 58 | assert delete_all_client.delete(f"{url}/{1}").status_code == 200 59 | -------------------------------------------------------------------------------- /tests/test_dependencies/test_per_route.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import Depends, HTTPException 3 | 4 | from tests.conftest import yield_test_client 5 | from tests.implementations import implementations 6 | 7 | URLS = ["/potato", "/carrot"] 8 | AUTH = {"Authorization": "Bearer my_token"} 9 | 10 | 11 | class RaisesException: 12 | def __init__(self, status_code: int): 13 | self.status_code = status_code 14 | 15 | def __call__(self): 16 | raise HTTPException(self.status_code) 17 | 18 | 19 | class NullDependency: 20 | def __init__(self, status_code: int): 21 | self.status_code = status_code 22 | 23 | def __call__(self): 24 | pass 25 | 26 | 27 | DEPENDS_KWARGS = dict( 28 | get_all_route=[Depends(NullDependency), Depends(RaisesException(401))], 29 | get_one_route=[Depends(NullDependency), Depends(RaisesException(402))], 30 | create_route=[Depends(NullDependency), Depends(RaisesException(403))], 31 | update_route=[Depends(NullDependency), Depends(RaisesException(404))], 32 | delete_all_route=[Depends(NullDependency), Depends(RaisesException(405))], 33 | delete_one_route=[Depends(NullDependency), Depends(RaisesException(406))], 34 | ) 35 | 36 | 37 | @pytest.fixture(params=implementations) 38 | def client(request): 39 | impl, dsn = request.param 40 | 41 | app, router, settings = impl(db_uri=dsn) 42 | [app.include_router(router(**s, **DEPENDS_KWARGS)) for s in settings] 43 | 44 | yield from yield_test_client(app, impl) 45 | 46 | 47 | @pytest.mark.parametrize("url", URLS) 48 | def test_route_disable(client, url): 49 | item_url = f"{url}/1" 50 | actions = [ 51 | (client.get, url), 52 | (client.get, item_url), 53 | (client.post, url), 54 | (client.put, item_url), 55 | (client.delete, url), 56 | (client.delete, item_url), 57 | ] 58 | 59 | err_codes = set() 60 | for method, url in actions: 61 | print(method, url, err_codes) 62 | status_code = method(url).status_code 63 | 64 | assert status_code != 200 65 | assert 400 <= status_code <= 406 66 | assert status_code not in err_codes 67 | 68 | err_codes.add(status_code) 69 | 70 | assert len(err_codes) == len(actions) 71 | -------------------------------------------------------------------------------- /tests/test_dependencies/test_top_level.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, HTTPException 2 | from fastapi.security import OAuth2PasswordBearer 3 | 4 | import pytest 5 | 6 | from tests.implementations import implementations 7 | from tests.conftest import yield_test_client 8 | 9 | URLS = ["/potato", "/carrot"] 10 | AUTH = {"Authorization": "Bearer my_token"} 11 | 12 | 13 | @pytest.fixture(params=implementations, scope="class") 14 | def client(request): 15 | impl, dsn = request.param 16 | 17 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") 18 | 19 | def token_auth(token: str = Depends(oauth2_scheme)): 20 | if not token: 21 | raise HTTPException(401, "Invalid token") 22 | 23 | app, router, settings = impl(db_uri=dsn) 24 | [ 25 | app.include_router(router(**s, dependencies=[Depends(token_auth)])) 26 | for s in settings 27 | ] 28 | 29 | yield from yield_test_client(app, impl) 30 | 31 | 32 | @pytest.fixture(params=URLS) 33 | def url(request): 34 | yield request.param 35 | 36 | 37 | class TestTopLevelDependencies: 38 | @staticmethod 39 | def test_authorization(client, url): 40 | assert client.get(url, headers=AUTH).status_code == 200 41 | assert client.post(url, headers=AUTH).status_code != 401 42 | assert client.delete(url, headers=AUTH).status_code == 200 43 | 44 | for id_ in [-1, 1, 0, 14]: 45 | assert client.get(f"{url}/{id_}", headers=AUTH).status_code != 401 46 | assert client.put(f"{url}/{id_}", headers=AUTH).status_code != 401 47 | assert client.delete(f"{url}/{id_}", headers=AUTH).status_code != 401 48 | 49 | @staticmethod 50 | def test_authorization_fail(client, url): 51 | assert client.get(url).status_code == 401 52 | assert client.get(url).status_code == 401 53 | assert client.post(url).status_code == 401 54 | 55 | for id_ in [-1, 1, 0, 14]: 56 | assert client.get(f"{url}/{id_}").status_code == 401 57 | assert client.put(f"{url}/{id_}").status_code == 401 58 | assert client.delete(f"{url}/{id_}").status_code == 401 59 | -------------------------------------------------------------------------------- /tests/test_exclude.py: -------------------------------------------------------------------------------- 1 | import random 2 | import pytest 3 | 4 | from fastapi import FastAPI, testclient 5 | from fastapi_crudrouter import MemoryCRUDRouter 6 | 7 | from tests import Potato 8 | 9 | URL = "/potato" 10 | 11 | 12 | def get_client(**kwargs): 13 | app = FastAPI() 14 | app.include_router(MemoryCRUDRouter(schema=Potato, prefix=URL, **kwargs)) 15 | 16 | return testclient.TestClient(app) 17 | 18 | 19 | @pytest.mark.parametrize("i", list(range(1, len(MemoryCRUDRouter.get_routes()) + 1))) 20 | def test_exclude_internal(i): 21 | keys = random.sample(MemoryCRUDRouter.get_routes(), k=i) 22 | kwargs = {r + "_route": False for r in keys} 23 | 24 | router = MemoryCRUDRouter(schema=Potato, prefix=URL, **kwargs) 25 | assert len(router.routes) == len(MemoryCRUDRouter.get_routes()) - i 26 | 27 | 28 | def test_exclude_delete_all(): 29 | client = get_client(delete_all_route=False) 30 | assert client.delete(URL).status_code == 405 31 | assert client.get(URL).status_code == 200 32 | 33 | 34 | def test_exclude_all(): 35 | routes = MemoryCRUDRouter.get_routes() 36 | kwargs = {r + "_route": False for r in routes} 37 | client = get_client(**kwargs) 38 | 39 | assert client.delete(URL).status_code == 404 40 | assert client.get(URL).status_code == 404 41 | assert client.post(URL).status_code == 404 42 | assert client.put(URL).status_code == 404 43 | 44 | for id_ in [-1, 1, 0, 14]: 45 | assert client.get(f"{URL}/{id_}").status_code == 404 46 | assert client.post(f"{URL}/{id_}").status_code == 404 47 | assert client.put(f"{URL}/{id_}").status_code == 404 48 | assert client.delete(f"{URL}/{id_}").status_code == 404 49 | -------------------------------------------------------------------------------- /tests/test_integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awtkns/fastapi-crudrouter/9b829865d85113a3f16f94c029502a9a584d47bb/tests/test_integration/__init__.py -------------------------------------------------------------------------------- /tests/test_integration/test_backend_not_installed.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | 4 | def test_virtualenv(virtualenv): 5 | file = pathlib.Path(__file__) 6 | package = file.parent.parent.parent 7 | assert (package / "fastapi_crudrouter").exists() 8 | 9 | virtualenv.run(f"pip install -e {package}") 10 | virtualenv.run(f"python {file}") 11 | 12 | 13 | if __name__ == "__main__": 14 | from fastapi_crudrouter import ( 15 | DatabasesCRUDRouter, 16 | GinoCRUDRouter, 17 | OrmarCRUDRouter, 18 | SQLAlchemyCRUDRouter, 19 | TortoiseCRUDRouter, 20 | ) 21 | 22 | routers = [ 23 | SQLAlchemyCRUDRouter, 24 | DatabasesCRUDRouter, 25 | OrmarCRUDRouter, 26 | TortoiseCRUDRouter, 27 | GinoCRUDRouter, 28 | ] 29 | 30 | for crud_router in routers: 31 | try: 32 | # noinspection PyTypeChecker 33 | crud_router(..., ..., ..., ...) 34 | except AssertionError: 35 | pass 36 | except Exception: 37 | raise AssertionError( 38 | f"Not checking that all requirements are installed when initializing the {crud_router.__name__}" 39 | ) 40 | -------------------------------------------------------------------------------- /tests/test_integration/test_typing.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | file = pathlib.Path(__file__) 4 | root_dir = file.parent.parent.parent 5 | package_src = root_dir / "fastapi_crudrouter" 6 | 7 | 8 | def test_py_typed_file_exists(): 9 | assert (package_src / "py.typed").exists() 10 | assert (package_src / "py.typed").is_file() 11 | 12 | 13 | def test_virtualenv(virtualenv): 14 | assert (root_dir).exists() 15 | assert (root_dir / "setup.py").exists() 16 | 17 | virtualenv.run(f"pip install -e {root_dir}") 18 | virtualenv.run(f"pip install mypy") 19 | virtualenv.run(f"mypy {file}") 20 | 21 | 22 | if __name__ == "__main__": 23 | from pydantic import BaseModel 24 | from fastapi import FastAPI 25 | 26 | import fastapi_crudrouter 27 | 28 | class User(BaseModel): 29 | id: int 30 | name: str 31 | email: str 32 | 33 | router = fastapi_crudrouter.MemoryCRUDRouter(schema=User) 34 | app = FastAPI() 35 | app.include_router(router) 36 | -------------------------------------------------------------------------------- /tests/test_integrity_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests import test_router 4 | 5 | POTATO_URL = "/potatoes" 6 | 7 | 8 | def test_integrity_error_create(integrity_errors_client): 9 | client = integrity_errors_client 10 | potato = dict(id=1, thickness=2, mass=5, color="red", type="russet") 11 | 12 | args = client, POTATO_URL, potato 13 | test_router.test_post(*args) 14 | with pytest.raises(AssertionError): 15 | test_router.test_post(*args) 16 | 17 | # No integrity error here because of the create_schema 18 | args = client, "/carrots", dict(id=1, length=2, color="red") 19 | test_router.test_post(*args) 20 | test_router.test_post(*args, expected_length=2) 21 | 22 | 23 | def test_integrity_error_update(integrity_errors_client): 24 | client = integrity_errors_client 25 | potato1 = dict(id=1, thickness=2, mass=5, color="red", type="russet") 26 | 27 | potato2 = dict(id=2, thickness=9, mass=5, color="yellow", type="mini") 28 | 29 | args = client, POTATO_URL 30 | test_router.test_post(*args, potato1, expected_length=1) 31 | test_router.test_post(*args, potato2, expected_length=2) 32 | 33 | potato2["color"] = potato1["color"] 34 | res = client.put(f'{POTATO_URL}/{potato2["id"]}', json=potato2) 35 | assert res.status_code == 422, res.json() 36 | 37 | potato2["color"] = "green" 38 | res = client.put(f'{POTATO_URL}/{potato2["id"]}', json=potato2) 39 | assert res.status_code == 200, res.json() 40 | -------------------------------------------------------------------------------- /tests/test_openapi_schema.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | 3 | from tests import CUSTOM_TAGS 4 | 5 | POTATO_TAGS = ["Potato"] 6 | PATHS = ["/potato", "/carrot"] 7 | PATH_TAGS = { 8 | "/potato": POTATO_TAGS, 9 | "/potato/{item_id}": POTATO_TAGS, 10 | "/carrot": CUSTOM_TAGS, 11 | "/carrot/{item_id}": CUSTOM_TAGS, 12 | } 13 | 14 | 15 | class TestOpenAPISpec: 16 | def test_schema_exists(self, client): 17 | res = client.get("/openapi.json") 18 | assert res.status_code == 200 19 | 20 | return res 21 | 22 | def test_schema_tags(self, client): 23 | schema = self.test_schema_exists(client).json() 24 | paths = schema["paths"] 25 | 26 | assert len(paths) == len(PATH_TAGS) 27 | for path, method in paths.items(): 28 | assert len(method) == 3 29 | 30 | for m in method: 31 | assert method[m]["tags"] == PATH_TAGS[path] 32 | 33 | @mark.parametrize("path", PATHS) 34 | def test_response_types(self, client, path): 35 | schema = self.test_schema_exists(client).json() 36 | paths = schema["paths"] 37 | 38 | for method in ["get", "post", "delete"]: 39 | assert "200" in paths[path][method]["responses"] 40 | 41 | assert "422" in paths[path]["post"]["responses"] 42 | 43 | item_path = path + "/{item_id}" 44 | for method in ["get", "put", "delete"]: 45 | assert "200" in paths[item_path][method]["responses"] 46 | assert "404" in paths[item_path][method]["responses"] 47 | assert "422" in paths[item_path][method]["responses"] 48 | -------------------------------------------------------------------------------- /tests/test_overloads.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import APIRouter 3 | 4 | from .implementations import implementations 5 | from .conftest import yield_test_client 6 | 7 | 8 | URLs = ["/potato", "/carrot"] 9 | PARAMS = [-1, 0, 1, 14, "ten"] 10 | 11 | GET_ALL = "Overloaded Get All" 12 | GET_ONE = "Overloaded Get One" 13 | CREATE_ONE = "Overloaded Post One" 14 | UPDATE_ONE = "Overloaded Update One" 15 | DELETE_ONE = "Overloaded Delete One" 16 | DELETE_ALL = "Overloaded Delete All" 17 | CUSTOM_ROUTE = "Custom Route" 18 | 19 | 20 | @pytest.fixture(params=implementations, scope="class") 21 | def overloaded_client(request): 22 | impl, dsn = request.param 23 | 24 | app, router, settings = impl(db_uri=dsn) 25 | routers = [router(**s) for s in settings] 26 | 27 | for r in routers: 28 | r: APIRouter 29 | 30 | @r.api_route("", methods=["GET"]) 31 | def overloaded_get_all(): 32 | return GET_ALL 33 | 34 | @r.get("/{item_id}") 35 | def overloaded_get_one(): 36 | return GET_ONE 37 | 38 | @r.post("") 39 | def overloaded_get(): 40 | return CREATE_ONE 41 | 42 | @r.put("/{item_id}") 43 | def overloaded_update(): 44 | return UPDATE_ONE 45 | 46 | @r.delete("/{item_id}") 47 | def overloaded_delete(): 48 | return DELETE_ONE 49 | 50 | @r.api_route("", methods=["DELETE"]) 51 | def overloaded_delete(): 52 | return DELETE_ALL 53 | 54 | @r.post("/custom") 55 | def custom_route(): 56 | return CUSTOM_ROUTE 57 | 58 | app.include_router(r) 59 | 60 | yield from yield_test_client(app, impl) 61 | 62 | 63 | @pytest.fixture(params=URLs) 64 | def url(request): 65 | yield request.param 66 | 67 | 68 | class TestOverloads: 69 | @staticmethod 70 | def check_response(res, expected): 71 | assert res.status_code == 200 72 | assert expected in res.text 73 | 74 | def test_get_all(self, overloaded_client, url): 75 | return self.check_response(overloaded_client.get(url), GET_ALL) 76 | 77 | @pytest.mark.parametrize("id_", PARAMS) 78 | def test_get_one(self, overloaded_client, url, id_): 79 | self.check_response(overloaded_client.get(f"{url}/{id_}"), GET_ONE) 80 | 81 | def test_create(self, overloaded_client, url): 82 | self.check_response(overloaded_client.post(url), CREATE_ONE) 83 | 84 | @pytest.mark.parametrize("id_", PARAMS) 85 | def test_update(self, overloaded_client, url, id_): 86 | self.check_response(overloaded_client.put(f"{url}/{id_}"), UPDATE_ONE) 87 | 88 | @pytest.mark.parametrize("id_", PARAMS) 89 | def test_delete(self, overloaded_client, url, id_): 90 | self.check_response(overloaded_client.delete(f"{url}/{id_}"), DELETE_ONE) 91 | 92 | def test_delete_all(self, overloaded_client, url): 93 | self.check_response(overloaded_client.delete(url), DELETE_ALL) 94 | 95 | def test_custom_route(self, overloaded_client, url): 96 | self.check_response(overloaded_client.post(f"{url}/custom"), CUSTOM_ROUTE) 97 | -------------------------------------------------------------------------------- /tests/test_pagination.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pytest 4 | 5 | from . import PAGINATION_SIZE, test_router 6 | 7 | PotatoUrl = "/potato" 8 | CarrotUrl = "/carrot" 9 | basic_carrot = dict(length=1.2, color="Orange") 10 | basic_potato = dict(thickness=0.24, mass=1.2, color="Brown", type="Russet") 11 | 12 | INSERT_COUNT = 20 13 | 14 | 15 | @pytest.fixture(scope="class") 16 | def insert_items( 17 | client, 18 | url: str = PotatoUrl, 19 | model: typing.Dict = None, 20 | count: int = INSERT_COUNT, 21 | ): 22 | model = model or basic_potato 23 | for i in range(count): 24 | test_router.test_post( 25 | client, 26 | url=url, 27 | model=model, 28 | expected_length=i + 1 if i + 1 < PAGINATION_SIZE else PAGINATION_SIZE, 29 | ) 30 | 31 | 32 | @pytest.fixture(scope="class") 33 | def insert_carrots(client): 34 | for i in range(INSERT_COUNT): 35 | test_router.test_post(client, CarrotUrl, basic_carrot, expected_length=i + 1) 36 | 37 | 38 | @pytest.fixture(scope="class") 39 | def cleanup(client): 40 | yield 41 | client.delete(CarrotUrl) 42 | client.delete(PotatoUrl) 43 | 44 | 45 | def get_expected_length(limit, offset, count: int = INSERT_COUNT): 46 | expected_length = limit 47 | if offset >= count: 48 | expected_length = 0 49 | 50 | elif offset + limit >= INSERT_COUNT: 51 | expected_length = INSERT_COUNT - offset 52 | 53 | return expected_length 54 | 55 | 56 | @pytest.mark.usefixtures("insert_carrots", "insert_items", "cleanup") 57 | class TestPagination: 58 | @pytest.mark.parametrize("offset", [0, 1, 5, 10, 20, 40]) 59 | @pytest.mark.parametrize("limit", [1, 5, 10]) 60 | def test_pagination(self, client, limit, offset): 61 | test_router.test_get( 62 | client=client, 63 | url=PotatoUrl, 64 | params={"limit": limit, "skip": offset}, 65 | expected_length=get_expected_length(limit, offset), 66 | ) 67 | 68 | @pytest.mark.parametrize("offset", [-1, "asdas", 3.23]) 69 | def test_invalid_offset(self, client, offset): 70 | res = client.get(PotatoUrl, params={"skip": offset}) 71 | assert res.status_code == 422 72 | 73 | @pytest.mark.parametrize("limit", [-1, 0, "asdas", 3.23, 21]) 74 | def test_invalid_limit(self, client, limit): 75 | res = client.get(PotatoUrl, params={"limit": limit}) 76 | assert res.status_code == 422 77 | 78 | def test_pagination_disabled(self, client): 79 | test_router.test_get(client, CarrotUrl, expected_length=INSERT_COUNT) 80 | 81 | @pytest.mark.parametrize("limit", [2, 5, 10]) 82 | def test_paging(self, client, limit): 83 | ids = set() 84 | skip = 0 85 | while skip < INSERT_COUNT: 86 | data = test_router.test_get( 87 | client, 88 | PotatoUrl, 89 | params={"limit": limit, "skip": skip}, 90 | expected_length=get_expected_length(limit, skip), 91 | ) 92 | 93 | for item in data: 94 | assert item["id"] not in ids 95 | ids.add(item["id"]) 96 | 97 | skip += limit 98 | 99 | assert len(ids) == INSERT_COUNT 100 | 101 | @pytest.mark.parametrize("limit", [2, 5, 10]) 102 | def test_paging_no_limit(self, client, limit): 103 | client.delete(CarrotUrl) 104 | for i in range(limit): 105 | res = client.post(url=CarrotUrl, json=basic_carrot) 106 | assert res.status_code == 200, res.json() 107 | 108 | res = client.get(CarrotUrl) 109 | assert res.status_code == 200, res.json() 110 | assert len(res.json()) == limit 111 | 112 | res = client.get(CarrotUrl, params={"limit": limit}) 113 | assert res.status_code == 200, res.json() 114 | assert len(res.json()) == limit 115 | 116 | limit = int(limit / 2) 117 | res = client.get(CarrotUrl, params={"limit": limit}) 118 | assert res.status_code == 200, res.json() 119 | assert len(res.json()) == limit 120 | -------------------------------------------------------------------------------- /tests/test_pks.py: -------------------------------------------------------------------------------- 1 | from . import test_router 2 | 3 | potato_type = dict(name="russet", origin="Canada") 4 | URL = "/potato_type" 5 | 6 | 7 | def test_get(string_pk_client): 8 | test_router.test_get(string_pk_client, URL) 9 | 10 | 11 | def test_post(string_pk_client): 12 | test_router.test_post(string_pk_client, URL, potato_type) 13 | 14 | 15 | def test_get_one(string_pk_client): 16 | test_router.test_get_one( 17 | string_pk_client, URL, dict(name="kenebec", origin="Ireland"), "name" 18 | ) 19 | 20 | 21 | def test_delete_one(string_pk_client): 22 | test_router.test_delete_one( 23 | string_pk_client, URL, dict(name="golden", origin="Ireland"), "name" 24 | ) 25 | 26 | 27 | def test_delete_all(string_pk_client): 28 | test_router.test_delete_all( 29 | string_pk_client, 30 | URL, 31 | dict(name="red", origin="Ireland"), 32 | dict(name="brown", origin="Ireland"), 33 | ) 34 | -------------------------------------------------------------------------------- /tests/test_prefix.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.implementations import implementations 4 | 5 | 6 | @pytest.fixture(params=implementations) 7 | def router(request): 8 | impl, dsn = request.param 9 | 10 | app, router, settings = impl(db_uri=dsn) 11 | kwargs = {**settings[0], **dict(prefix=None)} 12 | router = router(**kwargs) 13 | 14 | yield router 15 | 16 | 17 | def test_prefix_lowercase(router): 18 | assert type(router.prefix) is str 19 | assert router.prefix != "" 20 | assert router.prefix == router.prefix.lower() 21 | -------------------------------------------------------------------------------- /tests/test_router.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import pytest 4 | 5 | from .utils import compare_dict 6 | 7 | basic_potato = dict(thickness=0.24, mass=1.2, color="Brown", type="Russet") 8 | URL = "/potato" 9 | 10 | 11 | def test_get(client, url: str = URL, params: dict = None, expected_length: int = 0): 12 | res = client.get(url, params=params) 13 | data = res.json() 14 | 15 | assert res.status_code == 200, data 16 | assert type(data) == list and len(data) == expected_length 17 | 18 | return data 19 | 20 | 21 | def test_post( 22 | client, url: str = URL, model: Dict = None, expected_length: int = 1 23 | ) -> dict: 24 | model = model or basic_potato 25 | res = client.post(url, json=model) 26 | assert res.status_code == 200, res.json() 27 | 28 | data = client.get(url).json() 29 | assert len(data) == expected_length 30 | 31 | return res.json() 32 | 33 | 34 | def test_get_one(client, url: str = URL, model: Dict = None, id_key: str = "id"): 35 | model = model or basic_potato 36 | res = client.post(url, json=model) 37 | assert res.status_code == 200 38 | id_ = res.json()[id_key] 39 | 40 | data = client.get(url).json() 41 | assert len(data) 42 | 43 | res = client.get(f"{url}/{id_}") 44 | assert res.status_code == 200 45 | 46 | assert compare_dict(res.json(), model, exclude=[id_key]) 47 | 48 | 49 | def test_update(client, url: str = URL, model: Dict = None, id_key: str = "id"): 50 | test_get(client, url, expected_length=0) 51 | 52 | model = model or basic_potato 53 | res = client.post(url, json=model) 54 | data = res.json() 55 | assert res.status_code == 200 56 | 57 | test_get(client, url, expected_length=1) 58 | 59 | tuber = {k: v for k, v in model.items()} 60 | tuber["color"] = "yellow" 61 | 62 | res = client.put(f"{url}/{data[id_key]}", json=tuber) 63 | assert res.status_code == 200 64 | assert compare_dict(res.json(), tuber, exclude=[id_key]) 65 | assert not compare_dict(res.json(), model, exclude=[id_key]) 66 | 67 | res = client.get(f"{url}/{data[id_key]}") 68 | assert res.status_code == 200 69 | assert compare_dict(res.json(), tuber, exclude=[id_key]) 70 | assert not compare_dict(res.json(), model, exclude=[id_key]) 71 | 72 | 73 | def test_delete_one(client, url: str = URL, model: Dict = None, id_key: str = "id"): 74 | model = model or basic_potato 75 | res = client.post(url, json=model) 76 | data = res.json() 77 | assert res.status_code == 200 78 | 79 | res = client.get(f"{url}/{data[id_key]}") 80 | assert res.status_code == 200 81 | assert compare_dict(res.json(), model, exclude=[id_key]) 82 | 83 | length_before = len(client.get(url).json()) 84 | 85 | res = client.delete(f"{url}/{data[id_key]}") 86 | assert res.status_code == 200 87 | assert compare_dict(res.json(), model, exclude=[id_key]) 88 | 89 | res = client.get(url) 90 | assert res.status_code == 200 91 | assert len(res.json()) < length_before 92 | 93 | 94 | def test_delete_all( 95 | client, 96 | url: str = URL, 97 | model: Dict = None, 98 | model2: Dict = None, 99 | ): 100 | model = model or basic_potato 101 | model2 = model2 or basic_potato 102 | 103 | res = client.post(url, json=model) 104 | assert res.status_code == 200 105 | 106 | res = client.post(url, json=model2) 107 | assert res.status_code == 200 108 | 109 | assert len(client.get(url).json()) >= 2 110 | 111 | res = client.delete(url) 112 | assert res.status_code == 200 113 | assert len(res.json()) == 0 114 | 115 | assert len(client.get(url).json()) == 0 116 | 117 | 118 | @pytest.mark.parametrize("id_", [-1, 0, 4, "14"]) 119 | def test_not_found(client, id_, url: str = URL, model: Dict = None): 120 | url = f"{url}/{id_}" 121 | model = model or basic_potato 122 | assert client.get(url).status_code == 404 123 | assert client.put(url, json=model).status_code == 404 124 | assert client.delete(url).status_code == 404 125 | 126 | 127 | def test_dne(client): 128 | res = client.get("/") 129 | assert res.status_code == 404 130 | 131 | res = client.get(f"/tomatoes") 132 | assert res.status_code == 404 133 | -------------------------------------------------------------------------------- /tests/test_sqlalchemy_nested_.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi.testclient import TestClient 4 | from pydantic import BaseModel 5 | from sqlalchemy import Column, ForeignKey, Integer 6 | from sqlalchemy.orm import relationship 7 | 8 | from fastapi_crudrouter import SQLAlchemyCRUDRouter 9 | from tests import ORMModel, test_router 10 | from tests.implementations.sqlalchemy_ import _setup_base_app 11 | 12 | CHILD_URL = "/child" 13 | PARENT_URL = "/parent" 14 | 15 | 16 | class ChildSchema(ORMModel): 17 | parent_id: int 18 | 19 | 20 | class ParentSchema(ORMModel): 21 | children: List[ChildSchema] = [] 22 | 23 | 24 | class ParentCreate(BaseModel): 25 | pass 26 | 27 | 28 | def create_app(): 29 | app, engine, Base, session = _setup_base_app() 30 | 31 | class Child(Base): 32 | __tablename__ = "child" 33 | id = Column(Integer, primary_key=True, index=True) 34 | parent_id = Column(Integer, ForeignKey("parent.id")) 35 | 36 | class Parent(Base): 37 | __tablename__ = "parent" 38 | id = Column(Integer, primary_key=True, index=True) 39 | 40 | children = relationship(Child, backref="parent", lazy="joined") 41 | 42 | Base.metadata.create_all(bind=engine) 43 | parent_router = SQLAlchemyCRUDRouter( 44 | schema=ParentSchema, 45 | create_schema=ParentCreate, 46 | db_model=Parent, 47 | db=session, 48 | prefix=PARENT_URL, 49 | ) 50 | child_router = SQLAlchemyCRUDRouter( 51 | schema=ChildSchema, db_model=Child, db=session, prefix=CHILD_URL 52 | ) 53 | app.include_router(parent_router) 54 | app.include_router(child_router) 55 | 56 | return app 57 | 58 | 59 | def test_nested_models(): 60 | client = TestClient(create_app()) 61 | 62 | parent = test_router.test_post(client, PARENT_URL, dict()) 63 | test_router.test_post(client, CHILD_URL, dict(id=0, parent_id=parent["id"])) 64 | 65 | res = client.get(f'{PARENT_URL}/{parent["id"]}') 66 | assert res.status_code == 200, res.json() 67 | 68 | data = res.json() 69 | assert type(data["children"]) is list and data["children"], data 70 | -------------------------------------------------------------------------------- /tests/test_two_routers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import test_router 4 | from .utils import compare_dict 5 | 6 | basic_potato = dict(thickness=0.24, mass=1.2, color="Brown", type="Russet") 7 | basic_carrot = dict(length=1.2, color="Orange") 8 | 9 | PotatoUrl = "/potato" 10 | CarrotUrl = "/carrot" 11 | 12 | 13 | def test_get(client): 14 | test_router.test_get(client, PotatoUrl) 15 | test_router.test_get(client, CarrotUrl) 16 | 17 | 18 | def test_post(client): 19 | test_router.test_post(client, PotatoUrl, basic_potato) 20 | test_router.test_post(client, CarrotUrl, basic_carrot) 21 | 22 | 23 | def test_get_one(client): 24 | test_router.test_get_one(client, PotatoUrl, basic_potato) 25 | test_router.test_get_one(client, CarrotUrl, basic_carrot) 26 | 27 | 28 | def test_update(client): 29 | test_router.test_update(client, PotatoUrl, basic_potato) 30 | 31 | with pytest.raises(AssertionError): 32 | test_router.test_update(client, CarrotUrl, basic_carrot) 33 | 34 | res = client.post(CarrotUrl, json=basic_carrot) 35 | data = res.json() 36 | assert res.status_code == 200 37 | 38 | carrot = {k: v for k, v in basic_carrot.items()} 39 | carrot["color"] = "Red" 40 | carrot["length"] = 54.0 41 | 42 | res = client.put(f'{CarrotUrl}/{data["id"]}', json=carrot) 43 | assert res.status_code == 200 44 | assert not compare_dict(res.json(), carrot, exclude=["id"]) 45 | assert not compare_dict(res.json(), basic_carrot, exclude=["id"]) 46 | assert compare_dict(res.json(), carrot, exclude=["id", "color"]) 47 | 48 | res = client.get(f'{CarrotUrl}/{data["id"]}') 49 | assert res.status_code == 200 50 | assert not compare_dict(res.json(), carrot, exclude=["id"]) 51 | assert not compare_dict(res.json(), basic_carrot, exclude=["id"]) 52 | assert compare_dict(res.json(), carrot, exclude=["id", "color"]) 53 | 54 | 55 | def test_delete_one(client): 56 | test_router.test_delete_one(client, PotatoUrl, basic_potato) 57 | test_router.test_delete_one(client, CarrotUrl, basic_carrot) 58 | 59 | 60 | def test_delete_all(client): 61 | test_router.test_delete_all(client, PotatoUrl, basic_potato) 62 | test_router.test_delete_all(client, CarrotUrl, basic_carrot, basic_carrot) 63 | 64 | 65 | @pytest.mark.parametrize("id_", [-1, 0, 4, "14"]) 66 | def test_not_found(client, id_): 67 | test_router.test_not_found(client, id_, PotatoUrl, basic_potato) 68 | test_router.test_not_found(client, id_, CarrotUrl, basic_carrot) 69 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | def test_version(): 2 | import fastapi_crudrouter 3 | 4 | assert type(fastapi_crudrouter.__version__) is str 5 | 6 | 7 | def test_version_file(): 8 | from fastapi_crudrouter import _version 9 | 10 | assert type(_version.__version__) is str 11 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | def compare_dict(d1, d2, exclude: list) -> bool: 2 | exclude = exclude or ["id"] 3 | d1 = {k: v for k, v in d1.items() if k not in exclude} 4 | d2 = {k: v for k, v in d2.items() if k not in exclude} 5 | return d1 == d2 6 | --------------------------------------------------------------------------------