├── .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 |
--------------------------------------------------------------------------------