Skip to content

Commit 5f8e107

Browse files
committed
loguru added && offline docs support
1 parent 4ecc21d commit 5f8e107

File tree

12 files changed

+2037
-14
lines changed

12 files changed

+2037
-14
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ repos:
1919
args: ["--fix=lf"]
2020
description: Forces to replace line ending by the UNIX 'lf' character.
2121
- id: check-added-large-files
22-
args: ["--maxkb=1000"]
22+
args: ["--maxkb=2000"]
2323

2424
- repo: https://github.com/astral-sh/uv-pre-commit
2525
rev: 0.6.2

app/api/v1/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from fastapi import APIRouter, Depends
22

3-
from app.api.v1.endpoints import auth, items, users
3+
from app.api.v1.endpoints import auth, docs, items, users
44
from app.dependencies.auth import get_current_user
55

66
api_router = APIRouter()
7+
api_router.include_router(docs.router)
78
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
89
api_router.include_router(
910
users.router,

app/api/v1/endpoints/docs.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from fastapi import APIRouter, FastAPI, Request
2+
from fastapi.openapi.docs import (
3+
get_redoc_html,
4+
get_swagger_ui_html,
5+
get_swagger_ui_oauth2_redirect_html,
6+
)
7+
from fastapi.responses import HTMLResponse
8+
9+
router = APIRouter()
10+
11+
12+
@router.get("/docs", include_in_schema=False)
13+
async def swagger_ui_html(request: Request) -> HTMLResponse:
14+
"""Swagger UI.
15+
16+
Args:
17+
request: Current Request.
18+
19+
Returns:
20+
Rendered Swagger UI.
21+
"""
22+
app: FastAPI = request.app
23+
24+
return get_swagger_ui_html(
25+
openapi_url=app.openapi_url,
26+
title=f"{app.title} - Swagger UI",
27+
oauth2_redirect_url=str(request.url_for("swagger_ui_redirect")),
28+
swagger_js_url="/static/docs/swagger-ui-bundle.js",
29+
swagger_css_url="/static/docs/swagger-ui.css",
30+
)
31+
32+
33+
@router.get("/swagger-redirect", include_in_schema=False)
34+
async def swagger_ui_redirect() -> HTMLResponse:
35+
"""Redirect to swagger.
36+
37+
Returns:
38+
Redirect.
39+
"""
40+
return get_swagger_ui_oauth2_redirect_html()
41+
42+
43+
@router.get("/redoc", include_in_schema=False)
44+
async def redoc_html(request: Request) -> HTMLResponse:
45+
"""Redoc UI.
46+
47+
Args:
48+
request: Current Request.
49+
50+
Returns:
51+
Rendered Redoc UI.
52+
"""
53+
app: FastAPI = request.app
54+
55+
return get_redoc_html(
56+
openapi_url=app.openapi_url,
57+
title=f"{app.title} - ReDoc",
58+
redoc_js_url="/static/docs/redoc.standalone.js",
59+
)

app/api/v1/endpoints/items.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from fastapi import APIRouter, Depends, Query
44

5+
# from pydantic import Field
56
from app.dependencies.repositories import get_item_service
67
from app.models.item import Item, ItemCreate, ItemUpdate
78
from app.services.item_service import ItemService
@@ -13,6 +14,7 @@
1314
async def get_items( # noqa: PLR0913
1415
item_service: Annotated[ItemService, Depends(get_item_service)],
1516
skip: int = Query(0, ge=0, description="Number of items to skip"),
17+
# skip: int = Field(0, ge=0, description="Number of items to skip"), # FIXME: Not working
1618
limit: int = Query(100, ge=1, le=100, description="Number of items to return"),
1719
name: Optional[str] = Query(None, description="Filter items by name"),
1820
description: Optional[str] = Query(None, description="Filter items by description"),
@@ -27,6 +29,7 @@ async def get_items( # noqa: PLR0913
2729
sort_by=sort_by,
2830
order=order,
2931
)
32+
# return {"message": "Get all items"}
3033

3134

3235
@router.get("/{item_id}")

app/core/config.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from enum import StrEnum
12
from typing import Annotated
23

34
from pydantic import (
@@ -17,24 +18,33 @@
1718
]
1819

1920

20-
class Settings(BaseSettings):
21-
API_V1_STR: str = "/api/v1"
22-
PROJECT_NAME: str = "FastAPI Template"
21+
class LogLevel(StrEnum):
22+
"""Possible log levels."""
2323

24-
ENVIRONMENT: str = "dev"
24+
NOTSET = "NOTSET"
25+
DEBUG = "DEBUG"
26+
INFO = "INFO"
27+
WARNING = "WARNING"
28+
ERROR = "ERROR"
29+
FATAL = "FATAL"
2530

26-
# CORS configuration
27-
CORS_ORIGINS: list[CustomHttpUrlStr] = []
2831

29-
# JWT configuration
30-
SECRET_KEY: str = "CHANGE_ME_IN_PRODUCTION"
31-
ALGORITHM: str = "HS256"
32+
class Settings(BaseSettings):
3233
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
33-
34-
# OpenTelemetry settings
34+
ALGORITHM: str = "HS256"
35+
API_V1_STR: str = "/api/v1"
36+
CORS_ORIGINS: list[CustomHttpUrlStr] = []
37+
ENVIRONMENT: str = "dev"
38+
HOST: str = "127.0.0.1"
39+
LOG_LEVEL: LogLevel = LogLevel.INFO
40+
OTLP_ENDPOINT: CustomHttpUrlStr = ""
41+
PORT: int = 8000
42+
PROJECT_NAME: str = "FastAPI Template"
43+
RELOAD: bool = False
44+
SECRET_KEY: str = "CHANGE_ME_IN_PRODUCTION"
3545
TELEMETRY_ENABLED: bool = False
3646
TELEMETRY_LOGGING_ENABLED: bool = False
37-
OTLP_ENDPOINT: CustomHttpUrlStr = ""
47+
WORKERS: int = 1
3848

3949
class Config:
4050
case_sensitive = True

app/log.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import logging
2+
import sys
3+
from typing import Any, Union
4+
5+
from loguru import logger
6+
from opentelemetry.trace import INVALID_SPAN, INVALID_SPAN_CONTEXT, get_current_span
7+
8+
from app.core.config import settings
9+
10+
11+
class InterceptHandler(logging.Handler):
12+
"""
13+
Default handler from examples in loguru documentation.
14+
15+
This handler intercepts all log requests and
16+
passes them to loguru.
17+
18+
For more info see:
19+
https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging
20+
"""
21+
22+
def emit(self, record: logging.LogRecord) -> None: # pragma: no cover
23+
"""
24+
Propagates logs to loguru.
25+
26+
:param record: record to log.
27+
"""
28+
try:
29+
level: Union[str, int] = logger.level(record.levelname).name
30+
except ValueError:
31+
level = record.levelno
32+
33+
# Find caller from where originated the logged message
34+
frame, depth = logging.currentframe(), 2
35+
while frame.f_code.co_filename == logging.__file__:
36+
frame = frame.f_back # type: ignore
37+
depth += 1
38+
39+
logger.opt(depth=depth, exception=record.exc_info).log(
40+
level,
41+
record.getMessage(),
42+
)
43+
44+
45+
def record_formatter(record: dict[str, Any]) -> str: # pragma: no cover
46+
"""
47+
Formats the record.
48+
49+
This function formats message
50+
by adding extra trace information to the record.
51+
52+
:param record: record information.
53+
:return: format string.
54+
"""
55+
log_format = (
56+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> "
57+
"| <level>{level: <8}</level> "
58+
"| <magenta>trace_id={extra[trace_id]}</magenta> "
59+
"| <blue>span_id={extra[span_id]}</blue> "
60+
"| <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> "
61+
"- <level>{message}</level>\n"
62+
)
63+
64+
span = get_current_span()
65+
record["extra"]["span_id"] = 0
66+
record["extra"]["trace_id"] = 0
67+
if span != INVALID_SPAN:
68+
span_context = span.get_span_context()
69+
if span_context != INVALID_SPAN_CONTEXT:
70+
record["extra"]["span_id"] = format(span_context.span_id, "016x")
71+
record["extra"]["trace_id"] = format(span_context.trace_id, "032x")
72+
73+
if record["exception"]:
74+
log_format = f"{log_format}{{exception}}"
75+
76+
return log_format
77+
78+
79+
def configure_logging() -> None: # pragma: no cover
80+
"""Configures logging."""
81+
intercept_handler = InterceptHandler()
82+
83+
logging.basicConfig(handlers=[intercept_handler], level=logging.NOTSET)
84+
85+
for logger_name in logging.root.manager.loggerDict:
86+
if logger_name.startswith("uvicorn."):
87+
logging.getLogger(logger_name).handlers = []
88+
89+
# change handler for default uvicorn logger
90+
logging.getLogger("uvicorn").handlers = [intercept_handler]
91+
logging.getLogger("uvicorn.access").handlers = [intercept_handler]
92+
93+
# set logs output, level and format
94+
logger.remove()
95+
logger.add(
96+
sys.stdout,
97+
level=settings.LOG_LEVEL.value,
98+
format=record_formatter, # type: ignore
99+
)

app/main.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
1+
from pathlib import Path
2+
13
from fastapi import FastAPI
24
from fastapi.middleware.cors import CORSMiddleware
5+
from fastapi.responses import JSONResponse
6+
from fastapi.staticfiles import StaticFiles
37

48
from app.api.v1.api import api_router
59
from app.core.config import settings
610
from app.lifespan import lifespan_setup
11+
from app.log import configure_logging
12+
13+
APP_ROOT = Path(__file__).parent
14+
15+
configure_logging()
716

817
app = FastAPI(
918
title=settings.PROJECT_NAME,
19+
version="0.0.1",
20+
docs_url=None,
21+
redoc_url=None,
1022
openapi_url=f"{settings.API_V1_STR}/openapi.json",
1123
lifespan=lifespan_setup,
24+
default_response_class=JSONResponse,
1225
)
1326

1427
# Set up CORS
@@ -23,6 +36,9 @@
2336
# Include routers
2437
app.include_router(api_router, prefix=settings.API_V1_STR)
2538

39+
# Adds static directory. This directory is used to access swagger files.
40+
app.mount("/static", StaticFiles(directory=APP_ROOT / "static"), name="static")
41+
2642

2743
@app.get("/")
2844
async def root():

app/static/docs/redoc.standalone.js

Lines changed: 1804 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/static/docs/swagger-ui-bundle.js

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/static/docs/swagger-ui.css

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)