├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cookiecutter.json ├── hooks └── post_gen_project.py ├── tools └── test_template.sh └── {{cookiecutter.directory_name}} ├── .gitignore ├── Dockerfile ├── NOTICE ├── README.md ├── __main__.py ├── requirements.txt ├── sample.env ├── tests ├── __init__.py ├── acceptance │ ├── __init__.py │ └── test_status.py ├── base_test.py └── requirements.txt ├── tools └── scripts │ ├── export_openapi.py │ └── healthcheck.py └── {{cookiecutter.project_slug}} ├── __init__.py ├── app.py ├── documentation.py ├── exceptions ├── __init__.py └── api │ ├── __init__.py │ └── base.py ├── logger.py ├── middlewares.py ├── models └── __init__.py ├── routers.py ├── routes ├── __init__.py └── api.py ├── settings.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .idea/ 3 | .pytest_cache/ 4 | __pycache__/ 5 | fastapi-example/ 6 | .env 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.1 4 | 5 | - Deprecate CustomBaseModel 6 | 7 | ## 0.1.1 8 | 9 | - Add Dockerfile support 10 | 11 | ## 0.0.1 12 | 13 | - Initial release 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Gradiant 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 | # FastAPI Cookiecutter Template 2 | 3 | This repository serves as a Cookiecutter template to create a new FastAPI project, featuring: 4 | 5 | - Log records identified per request, using [loguru](https://github.com/Delgan/loguru) 6 | - Settings management using [Pydantic BaseSettings](https://pydantic-docs.helpmanual.io/usage/settings/), through .env file or environment variables, organized by classes 7 | - Custom API exceptions and middleware that transforms API exceptions into FastAPI responses 8 | - Customization of generated documentation (self-hosting JS/CSS and changing ReDoc logo) 9 | 10 | ## Getting started 11 | 12 | - [Install cookiecutter](https://cookiecutter.readthedocs.io/en/latest/installation.html#install-cookiecutter) 13 | 14 | - Start a new project: 15 | `cookiecutter https://github.com/Gradiant/fastapi-cookiecutter-template.git` 16 | 17 | The following will be prompted: 18 | - app_name: canonical name of the project (example: "FastAPI example") 19 | - directory_name: name of the directory where the template will be created (example: "fastapi-example"; the directory will be created within the current directory) 20 | - project_slug: name of the Python package that will hold the application (example: "fastapi_example") 21 | - advanced_docs: if yes, adds more options to customize the generation of documentation 22 | - advanced_responses: if yes, adds more features to the returned responses 23 | 24 | - chdir to where the template was created (inside "directory_name") and start the server with `python .` 25 | 26 | ## Implementation details 27 | 28 | ### Routers 29 | 30 | This template uses [different modules of "routes"](https://fastapi.tiangolo.com/tutorial/bigger-applications/#apirouter) to declare the endpoints organized by context. For example, an API with "users" and "posts" CRUD endpoints would have one "routes/users.py" module, and other "routes/posts.py" module, where the endpoints for "users" and "posts" would be defined, respectively. 31 | 32 | In each module, an APIRouter is defined. The template includes a router module [routes/api.py]({{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/routes/api.py), with a sample "/status" endpoint. 33 | This router is then imported in [routers.py]({{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/routers.py) and declared in the setup_routes() function, so the API can use it. 34 | When including a router, a common prefix can be set to each router. 35 | Additionally, each router can have a "tag" associated, which is used to group its endpoints together in the generated documentation. 36 | 37 | ### Settings 38 | 39 | The settings are managed using [Pydantic BaseSettings](https://pydantic-docs.helpmanual.io/usage/settings/) classes, all of them contained in the [settings.py]({{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/settings.py) module. The main features and advantages of using these classes are: 40 | 41 | - The settings are automatically loaded from environment variables or a .env file (having the environment variables more priority). 42 | - The default name for the .env file is `.env`, and is loaded relative to the working directory where the application was launched from. The name of the file can be changed with the environment variable `ENV_FILE`. 43 | - The fields defined in a class are automatically validated and parsed into the declared datatype. Since environment variables are loaded as strings, we could define a setting as an integer, and Pydantic would validate if the setting is a valid integer, and parse to it. [Pydantic supports many field types](https://pydantic-docs.helpmanual.io/usage/types/). 44 | - The template proposal is to organize the settings by groups. This allows not only to keep them organized, but also using common prefixes for the settings. For example, the class APIDocsSettings is configured to use "API_DOCS_" as prefix; this means the attribute "title" will be loaded from a variable named "API_DOCS_TITLE" (can be upper, lower or mixed case). 45 | - The settings classes are initialized once within the module, and these instances directly imported where required. 46 | 47 | The bundled settings are documented in the [sample.env]({{cookiecutter.directory_name}}/sample.env) file. 48 | 49 | ### Logging & Middleware 50 | 51 | The proposed logging system consists of a single logger (using [loguru](https://github.com/Delgan/loguru) as logging library) that should only be user for logging records triggered from request handling (this means anything that runs from a request handler - any function decorated to serve an endpoint). 52 | 53 | All the requests are passed through the [request middleware]({{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/middlewares.py), that will append a unique identifier to the log records of that request, using context variables (using [loguru contextualize](https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.contextualize)). 54 | 55 | If the template is used with advanced_responses=yes, the returned responses will embed the request id and elapsed request processing time (seconds) in headers, as "X-Request-ID" and "X-Process-Time" respectively. 56 | Having the request id as a client can be useful to search later on all the log records for a certain request. 57 | 58 | The logger behaviour supported by the template is to print out the log records with a certain format. Optionally, using the REQUEST_LOG_SERIALIZE setting, these records can be serialized and printed out as JSON objects, that could then be persisted, and even grouped by request using the request identifier that is part of each record "extra" data. 59 | 60 | ### Exceptions 61 | 62 | The proposed procedure to return errors to clients is by raising a custom exception that inherits from the bundled [BaseAPIException]({{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/exceptions/api/base.py). 63 | This class includes an abstract method "response()" that must return a FastAPI Response when implemented in custom exception classes. An exception for returning Internal Server (500) errors is included. 64 | 65 | Any exception inherited from BaseAPIException that is raised from any point during a request handling will be captured by the request middleware. 66 | The "response()" method is called from the middleware to obtain the response that will finally be returned to the client. Unknown exceptions will be returned as Internal Server errors, using the bundled namesake exception. 67 | 68 | ### Advanced docs (self-hosting Swagger/ReDoc requirements) 69 | 70 | If the template is used with advanced_docs=yes, a [documentation.py]({{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/documentation.py) module will be created. 71 | In this module, the default logic to generate the API documentation is overriden to support self-hosting the Swagger/OpenAPI and ReDoc requirements (JS, CSS and Favicon files), so they get loaded from the local deployment instead of CDNs, as stated in the [FastAPI documentation](https://fastapi.tiangolo.com/advanced/extending-openapi/#self-hosting-javascript-and-css-for-docs). Self-hosting these files requires to set a static path on the API_DOCS_STATIC_PATH setting; refer to the [sample.env]({{cookiecutter.directory_name}}/sample.env) file. 72 | 73 | ## Running tests 74 | 75 | The template includes a [tests]({{cookiecutter.directory_name}}/tests) package, where tests for the application can be placed. Includes a sample test on the /status endpoint, using [FastAPI TestClient](https://fastapi.tiangolo.com/tutorial/testing/). 76 | 77 | The template tests can run without creating a template, by running the [tools/test_template.sh](tools/test_template.sh) script. 78 | 79 | ## Future improvements 80 | 81 | - API Exceptions linked with models, to be shown in the auto-generated documentation as Responses 82 | - Include an example with configured routes, exceptions and models 83 | - Optionally run with Gunicorn 84 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_name": "FastAPI Example", 3 | "directory_name": "{{ cookiecutter.app_name|lower|replace(' ', '-') }}", 4 | "project_slug": "{{ cookiecutter.directory_name|replace('-', '_') }}", 5 | "advanced_docs": ["no", "yes"], 6 | "advanced_responses": ["no", "yes"] 7 | } 8 | -------------------------------------------------------------------------------- /hooks/post_gen_project.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | 5 | def _delete_resource(path): 6 | if os.path.isfile(path): 7 | print("Removing file", path) 8 | os.remove(path) 9 | elif os.path.isdir(path): 10 | print("Removing directory", path) 11 | shutil.rmtree(path) 12 | else: 13 | print("Invalid resource", path) 14 | exit(1) 15 | 16 | 17 | def delete_unneccessary_files(): 18 | if "{{cookiecutter.advanced_docs}}" == "no": 19 | _delete_resource("{{cookiecutter.project_slug}}/documentation.py") 20 | 21 | 22 | if __name__ == "__main__": 23 | print("Running post_gen_project hook") 24 | delete_unneccessary_files() 25 | -------------------------------------------------------------------------------- /tools/test_template.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | test_id=$(uuidgen) 6 | directory_name="fastapi-cookiecutter-test_$test_id" 7 | current_path="$(pwd)" 8 | 9 | (cd /tmp && cookiecutter --no-input "$current_path" app_name="FastAPI+Cookiecutter test $test_id" directory_name="$directory_name" project_slug="fastapi_cookiecutter_test") 10 | 11 | set +ex 12 | (cd "/tmp/$directory_name" && pytest -sv .) 13 | test_exit_code=$? 14 | 15 | set -x 16 | rm -r "/tmp/$directory_name" 17 | exit $test_exit_code 18 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .idea/ 3 | .pytest_cache/ 4 | __pycache__/ 5 | .env 6 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/Dockerfile: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.advanced_docs == "yes" -%} 2 | FROM curlimages/curl:latest as static 3 | 4 | RUN mkdir /home/curl_user/static && cd /home/curl_user/static && curl \ 5 | -O "https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-bundle.js" \ 6 | -O "https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css" \ 7 | -O "https://raw.githubusercontent.com/go-swagger/go-swagger/master/docs/favicon.ico" \ 8 | -O "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js" 9 | {% endif %} 10 | FROM python:3.8-slim 11 | 12 | ARG USER=user 13 | ARG UID=1000 14 | 15 | # Extend PATH to use pip-installed tools from shell (like alembic) 16 | ENV PATH="${PATH}:/home/$USER/.local/bin" 17 | 18 | # Add non-root user 19 | RUN adduser --uid $UID --disabled-password --gecos '' $USER 20 | USER $USER 21 | WORKDIR /home/$USER 22 | 23 | {% if cookiecutter.advanced_docs == "yes" -%} 24 | # Copy static files 25 | ENV API_DOCS_STATIC_PATH="/home/$USER/static" 26 | RUN mkdir static 27 | COPY --from=static --chown=$USER /home/curl_user/static/* ./static/ 28 | {% endif %} 29 | # Install requirements 30 | # (copy isolated from project root, to avoid having to install everything again if something on the project sources changed, but not in requirements) 31 | COPY ./requirements.txt /tmp/requirements.txt 32 | RUN pip install --user --no-cache-dir -r /tmp/requirements.txt 33 | 34 | # Copy project files 35 | COPY ./ ./app/ 36 | WORKDIR /home/$USER/app 37 | 38 | HEALTHCHECK --interval=1m --timeout=10s --retries=3 --start-period=30s CMD python tools/scripts/healthcheck.py 39 | CMD ["python", "-u", "."] 40 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/NOTICE: -------------------------------------------------------------------------------- 1 | This software is created using the "fastapi-cookiecutter" template created by Gradiant: https://github.com/Gradiant/fastapi-cookiecutter-template 2 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/README.md: -------------------------------------------------------------------------------- 1 | # {{cookiecutter.app_name}} 2 | 3 | 4 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/__main__.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.project_slug}}.app import run 2 | 3 | run() 4 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.63.0 2 | pydantic==1.8.1 3 | uvicorn==0.13.4 4 | loguru==0.5.3 5 | python-dotenv==0.17.0 6 | {% if cookiecutter.advanced_docs == "yes" -%} 7 | aiofiles==0.6.0 8 | {% endif -%} 9 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/sample.env: -------------------------------------------------------------------------------- 1 | # # # API Server Settings # # # 2 | 3 | # Port where to expose the server (default=5000) 4 | API_PORT=5000 5 | 6 | # Host where to bind the server (0.0.0.0 to bind all) (default=0.0.0.0) 7 | API_HOST=0.0.0.0 8 | 9 | 10 | # # # API Docs Settings # # # 11 | 12 | # App Title, as shown in generated documentation (default="{{ cookiecutter.app_name }}") 13 | API_DOCS_TITLE="{{ cookiecutter.app_name }}" 14 | 15 | # App Description, as shown in generated documentation (default=nothing) 16 | #API_DOCS_DESCRIPTION="FastAPI project" 17 | 18 | # App Version, as shown in generated documentation (default="version") 19 | #API_DOCS_VERSION="0.0.1" 20 | 21 | {% if cookiecutter.advanced_docs == "yes" -%} 22 | # Custom logo URL to show in generated ReDoc documentation (default=nothing) 23 | #API_DOCS_CUSTOM_LOGO="" 24 | 25 | # If a static path is set, external dependencies used for OpenAPI/ReDoc documentation will be self-hosted instead of loaded from external CDN servers. (default=nothing) 26 | # The path must be a local accessible directory, absolute or relative. The files required for each documentation platform are: 27 | # - Swagger (OpenAPI): "swagger-ui.bundle.js", "swagger-ui.css", "favicon.ico" 28 | # - ReDoc: "redoc.standalone.js", "favicon.ico" 29 | # The files can be downloaded from: https://fastapi.tiangolo.com/advanced/extending-openapi/#download-the-files 30 | #API_DOCS_STATIC_PATH="static" 31 | {% endif %} 32 | 33 | 34 | # # # Request Logging Settings # # # 35 | 36 | # Minimal log level for request logs. One of: TRACE, DEBUG, INFO, WARNING, ERROR (default=DEBUG) 37 | REQUEST_LOG_LEVEL=TRACE 38 | 39 | # If enabled, request logs will be output as JSON, useful for persistence (default=no) 40 | REQUEST_LOG_SERIALIZE=no 41 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/fastapi-cookiecutter-template/d99556638d4ceda9f811caa312e48c128a64b0fb/{{cookiecutter.directory_name}}/tests/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/tests/acceptance/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/fastapi-cookiecutter-template/d99556638d4ceda9f811caa312e48c128a64b0fb/{{cookiecutter.directory_name}}/tests/acceptance/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/tests/acceptance/test_status.py: -------------------------------------------------------------------------------- 1 | from tests.base_test import BaseAPITest 2 | 3 | 4 | class TestStatus(BaseAPITest): 5 | def test_get_status(self): 6 | """Request the status endpoint. Should return status code 200""" 7 | r = self._request("GET", "/status") 8 | assert r.status_code == 200 9 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/tests/base_test.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi.testclient import TestClient 4 | from requests import Session, Response 5 | 6 | from {{cookiecutter.project_slug}}.app import app 7 | 8 | 9 | class BaseAPITest: 10 | """Base API test class that starts a fastapi TestClient (https://fastapi.tiangolo.com/tutorial/testing/).""" 11 | client: Session 12 | 13 | @classmethod 14 | def setup_class(cls): 15 | with TestClient(app) as client: 16 | # Usage of context-manager to trigger app events when using TestClient: 17 | # https://fastapi.tiangolo.com/advanced/testing-events/ 18 | cls.client = client 19 | 20 | def _request(self, method: str, endpoint: str, body: Optional[dict] = None, **kwargs) -> Response: 21 | """Perform a generic HTTP request against an endpoint of the API""" 22 | return self.client.request(method=method, url=endpoint, json=body, **kwargs) 23 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest~=6.2.3 2 | requests~=2.25.1 3 | wait4it==0.2.1 4 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/tools/scripts/export_openapi.py: -------------------------------------------------------------------------------- 1 | """This script exports the OpenAPI schema as JSON on the given location (via arg). 2 | Must run from project root path (where the __main__.py file is located). 3 | 4 | Example usage: python export_openapi.py docs/my_openapi.json 5 | """ 6 | 7 | import json 8 | import os 9 | import sys 10 | 11 | try: 12 | from {{cookiecutter.project_slug}}.app import app 13 | except ModuleNotFoundError: 14 | sys.path.append(os.getcwd()) 15 | from {{cookiecutter.project_slug}}.app import app 16 | 17 | 18 | def get_schema(): 19 | return app.openapi() 20 | 21 | 22 | def save_schema(filename, sch): 23 | with open(filename, "w") as f: 24 | f.write(json.dumps(sch, indent=2)) 25 | 26 | 27 | if __name__ == '__main__': 28 | file_path = sys.argv[-1] 29 | if not file_path.endswith(".json"): 30 | print("Path must be a JSON file!") 31 | exit(1) 32 | 33 | schema = get_schema() 34 | save_schema(file_path, schema) 35 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/tools/scripts/healthcheck.py: -------------------------------------------------------------------------------- 1 | """HEALTHCHECK Script 2 | Perform HTTP requests against the API /status endpoint, as Docker healthchecks 3 | """ 4 | 5 | import os 6 | import sys 7 | import urllib.request 8 | import urllib.error 9 | 10 | try: 11 | from {{cookiecutter.project_slug}}.settings import api_settings 12 | except ModuleNotFoundError: 13 | sys.path.append(os.getcwd()) 14 | from {{cookiecutter.project_slug}}.settings import api_settings 15 | 16 | 17 | def healthcheck(): 18 | try: 19 | with urllib.request.urlopen(f"http://localhost:{api_settings.port}/status") as response: 20 | code = response.getcode() 21 | text = response.read().decode() 22 | print(f"Healthcheck response ({code}): {text}") 23 | except urllib.error.URLError as ex: 24 | print(f"Healthcheck failed ({ex})") 25 | 26 | 27 | if __name__ == "__main__": 28 | healthcheck() -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/fastapi-cookiecutter-template/d99556638d4ceda9f811caa312e48c128a64b0fb/{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/app.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI 3 | 4 | from .middlewares import request_handler 5 | from .routers import setup_routes 6 | {%- if cookiecutter.advanced_docs == "no" %} 7 | from .routers import TAGS_METADATA 8 | {% else %} 9 | from .documentation import setup_documentation 10 | {% endif -%} 11 | from .settings import api_settings, api_docs_settings 12 | 13 | {% if cookiecutter.advanced_docs == "yes" -%} 14 | app = FastAPI( 15 | docs_url="/docs" if not api_docs_settings.static_path else None, 16 | redoc_url="/redoc" if not api_docs_settings.static_path else None 17 | ) 18 | {% else -%} 19 | app = FastAPI( 20 | title=api_docs_settings.title, 21 | version=api_docs_settings.version, 22 | openapi_tags=TAGS_METADATA 23 | ) 24 | {% endif -%} 25 | app.middleware("http")(request_handler) 26 | setup_routes(app) 27 | {%- if cookiecutter.advanced_docs == "yes" %} 28 | setup_documentation(app) 29 | {%- endif %} 30 | 31 | 32 | def run(): 33 | """Run the API using Uvicorn""" 34 | uvicorn.run( 35 | app, 36 | host=api_settings.host, 37 | port=api_settings.port 38 | ) 39 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/documentation.py: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.advanced_docs == "yes" -%} 2 | from fastapi import FastAPI 3 | from fastapi.openapi.utils import get_openapi 4 | from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html 5 | 6 | from .routers import TAGS_METADATA 7 | from .settings import api_docs_settings as settings 8 | 9 | 10 | def _custom_openapi(app: FastAPI): 11 | """Custom OpenAPI schema generator function, supporting: 12 | 13 | - Cache the schema 14 | - Set custom logo in ReDoc 15 | """ 16 | if app.openapi_schema: 17 | return app.openapi_schema 18 | 19 | openapi_schema = get_openapi( 20 | title=settings.title, 21 | version=settings.version, 22 | routes=app.routes, 23 | tags=TAGS_METADATA 24 | ) 25 | 26 | if settings.custom_logo: 27 | openapi_schema["info"]["x-logo"] = dict(url=settings.custom_logo) 28 | 29 | app.openapi_schema = openapi_schema 30 | return openapi_schema 31 | 32 | 33 | def _setup_docs_with_statics(app: FastAPI): 34 | from fastapi.staticfiles import StaticFiles 35 | app.mount("/static", StaticFiles(directory=settings.static_path), name="static") 36 | app.docs_url = None 37 | app.redoc_url = None 38 | 39 | @app.get("/docs", include_in_schema=False) 40 | async def custom_swagger_ui_html(): 41 | return get_swagger_ui_html( 42 | openapi_url=app.openapi_url, 43 | title=app.title + " - Swagger UI", 44 | oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, 45 | swagger_js_url="/static/swagger-ui-bundle.js", 46 | swagger_css_url="/static/swagger-ui.css", 47 | swagger_favicon_url="/static/favicon.ico" 48 | ) 49 | 50 | @app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False) 51 | async def swagger_ui_redirect(): 52 | return get_swagger_ui_oauth2_redirect_html() 53 | 54 | @app.get("/redoc", include_in_schema=False) 55 | async def custom_redoc_html(): 56 | return get_redoc_html( 57 | openapi_url=app.openapi_url, 58 | title=app.title + " - ReDoc", 59 | redoc_js_url="/static/redoc.standalone.js", 60 | redoc_favicon_url="/static/favicon.ico", 61 | with_google_fonts=False 62 | ) 63 | 64 | 65 | def setup_documentation(app: FastAPI): 66 | app.openapi = lambda: _custom_openapi(app) 67 | if settings.static_path: 68 | _setup_docs_with_statics(app) 69 | 70 | {%- endif %} 71 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/fastapi-cookiecutter-template/d99556638d4ceda9f811caa312e48c128a64b0fb/{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/exceptions/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/exceptions/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/fastapi-cookiecutter-template/d99556638d4ceda9f811caa312e48c128a64b0fb/{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/exceptions/api/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/exceptions/api/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from fastapi.responses import Response, PlainTextResponse 4 | 5 | 6 | class BaseAPIException(Exception, abc.ABC): 7 | @abc.abstractmethod 8 | def response(self) -> Response: 9 | pass 10 | 11 | 12 | class InternalServerException(BaseAPIException): 13 | def response(self): 14 | return PlainTextResponse( 15 | content="Internal server error", 16 | status_code=500 17 | ) 18 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/logger.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from loguru import logger 4 | 5 | from .settings import request_logging_settings as settings 6 | 7 | LoggerFormat = "{time:YY-MM-DD HH:mm:ss.SSS} | " \ 8 | "{level: <8} | " \ 9 | "{message} | {extra}" 10 | 11 | logger.remove() 12 | logger.add( 13 | sys.stderr, 14 | level=settings.level.upper(), 15 | format=LoggerFormat, 16 | serialize=settings.serialize, 17 | enqueue=True, # process logs in background 18 | diagnose=False # hide variable values in log backtrace 19 | ) 20 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/middlewares.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request, Response 2 | 3 | from .exceptions.api.base import BaseAPIException, InternalServerException 4 | from .logger import logger 5 | from .utils import get_time, get_uuid 6 | 7 | 8 | async def request_handler(request: Request, call_next): 9 | """Middleware used by FastAPI to process each request, featuring: 10 | 11 | - Contextualize request logs with an unique Request ID (UUID4) for each unique request. 12 | - Catch exceptions during the request handling. Translate custom API exceptions into responses, 13 | or treat (and log) unexpected exceptions. 14 | """ 15 | start_time = get_time(seconds_precision=False) 16 | request_id = get_uuid() 17 | 18 | with logger.contextualize(request_id=request_id): 19 | logger.bind(url=str(request.url), method=request.method).info("Request started") 20 | 21 | # noinspection PyBroadException 22 | try: 23 | response: Response = await call_next(request) 24 | 25 | except BaseAPIException as ex: 26 | response = ex.response() 27 | if response.status_code < 500: 28 | logger.bind(exception=str(ex)).info("Request did not succeed due to client-side error") 29 | else: 30 | logger.opt(exception=True).warning("Request did not succeed due to server-side error") 31 | 32 | except Exception: 33 | logger.opt(exception=True).error("Request failed due to unexpected error") 34 | response = InternalServerException().response() 35 | 36 | end_time = get_time(seconds_precision=False) 37 | time_elapsed = round(end_time - start_time, 5) 38 | {%- if cookiecutter.advanced_responses == "yes" %} 39 | response.headers["X-Request-ID"] = request_id 40 | response.headers["X-Process-Time"] = str(time_elapsed) 41 | {%- endif %} 42 | logger.bind(time_elapsed=time_elapsed, response_status=response.status_code).info("Request ended") 43 | return response 44 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/fastapi-cookiecutter-template/d99556638d4ceda9f811caa312e48c128a64b0fb/{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/models/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/routers.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from .routes import api 4 | 5 | 6 | def setup_routes(app: FastAPI): 7 | """Each Router specified in routes/* must be referenced in setup_routes(), 8 | as a new app.include_router() call.""" 9 | app.include_router( 10 | api.router, 11 | prefix="", 12 | tags=["api"] 13 | ) 14 | 15 | 16 | TAGS_METADATA = [ 17 | { 18 | "name": "api", 19 | "description": "General system endpoints for the API." 20 | } 21 | ] 22 | """Tags are used in generated documentation for grouping endpoints. 23 | In the metadata a description can be provided for each tag. 24 | It is not mandatory to declare all tags in this array - only tags where the description is set.""" 25 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gradiant/fastapi-cookiecutter-template/d99556638d4ceda9f811caa312e48c128a64b0fb/{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/routes/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/routes/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from fastapi.responses import PlainTextResponse 3 | 4 | router = APIRouter() 5 | 6 | 7 | @router.get( 8 | "/status", 9 | description="Get API status." 10 | ) 11 | def get_status(): 12 | return PlainTextResponse(status_code=200, content="OK") 13 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | import pydantic 5 | 6 | ENV_FILE = os.getenv("ENV_FILE", ".env") 7 | 8 | 9 | class BaseSettings(pydantic.BaseSettings): 10 | """Base class for loading settings. 11 | The setting variables are loaded from environment settings first, then from the defined env_file. 12 | 13 | Different groups/contexts of settings are created using different classes, that can define an env_prefix which 14 | will be concatenated to the start of the variable name.""" 15 | class Config: 16 | env_file = ENV_FILE 17 | 18 | 19 | class APISettings(BaseSettings): 20 | """Settings related with the FastAPI server""" 21 | host: str = "0.0.0.0" 22 | port: int = 5000 23 | 24 | class Config(BaseSettings.Config): 25 | env_prefix = "API_" 26 | 27 | 28 | class APIDocsSettings(BaseSettings): 29 | """Settings related with the API autogenerated documentation""" 30 | title: str = "{{ cookiecutter.app_name }}" 31 | """Title of the API""" 32 | description: Optional[str] = None 33 | """Description of the API""" 34 | version: str = "version" 35 | """Version of the API""" 36 | {%- if cookiecutter.advanced_docs == "yes" %} 37 | custom_logo: Optional[str] = None 38 | """URL of a custom logo to show in ReDoc (if not set, no logo will be shown)""" 39 | static_path: Optional[str] = None 40 | """Path (absolute or relative) where to load static files from, used for the generated documentation. 41 | If set, both OpenAPI/Swagger and ReDoc will load the required files from there, instead of the default CDN. 42 | More information available in FastAPI documentation: 43 | https://fastapi.tiangolo.com/advanced/extending-openapi/#download-the-files 44 | 45 | - Swagger UI requires the files "swagger-ui.bundle.js", "swagger-ui.css", "favicon.ico" 46 | - ReDoc requires the file "redoc.standalone.js", "favicon.ico" 47 | """ 48 | {%- endif %} 49 | 50 | class Config(BaseSettings.Config): 51 | env_prefix = "API_DOCS_" 52 | 53 | 54 | class RequestLoggingSettings(BaseSettings): 55 | """Settings related with the logging of requests""" 56 | level: str = "DEBUG" 57 | serialize: bool = False 58 | 59 | class Config(BaseSettings.Config): 60 | env_prefix = "REQUEST_LOG_" 61 | 62 | 63 | api_settings = APISettings() 64 | api_docs_settings = APIDocsSettings() 65 | request_logging_settings = RequestLoggingSettings() 66 | -------------------------------------------------------------------------------- /{{cookiecutter.directory_name}}/{{cookiecutter.project_slug}}/utils.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | from uuid import uuid4 3 | 4 | 5 | def get_time(seconds_precision=True): 6 | """Return current time as Unix/Epoch timestamp, in seconds. 7 | :param seconds_precision: if True, return with seconds precision as integer (default). 8 | If False, return with milliseconds precision as floating point number of seconds. 9 | """ 10 | return time() if not seconds_precision else int(time()) 11 | 12 | 13 | def get_uuid(): 14 | """Return a UUID4 as string""" 15 | return str(uuid4()) 16 | --------------------------------------------------------------------------------