Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 16b47e0

Browse files
committed
Reworked FastAPI utilities for smooth integration with Google Cloud RUN service
1 parent 443964b commit 16b47e0

File tree

24 files changed

+962
-252
lines changed

24 files changed

+962
-252
lines changed

README.md

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,128 @@
44

55
# Introduction
66

7-
This project provides collection of utilities for FastAPI framework as:
7+
This project provides collection of utilities for smooth integration of FastAPI framework with Google Cloud Platform services as logging and tracing.
88

9-
* Custom Middlewares
10-
* Exception Catchers
11-
* Custom Routes
9+
The key features of this project are:
10+
11+
* Logging to Stackdriver
12+
* Tracing to Stackdriver
13+
* Custom middleware for configuration of logging
14+
* Custom exception handlers treating HTTP and validation exceptions
15+
* Custom routes for documentation and favicon
16+
* Custom responses with statuses `success`, `warning` and `error` and standardized error messages
1217

1318
# Quick Start
1419

1520
This section shows how to use the utilities provided by this project:
1621

1722
```python
18-
from fastapi import FastAPI, Request
23+
"""File main.py with FastAPI app"""
24+
import os
1925
from fastapi.exceptions import RequestValidationError
2026
from starlette.exceptions import HTTPException
21-
22-
from surquest.fastapi.utils.route import Route
23-
from surquest.fastapi.utils.middleware import BasicMiddleware
24-
from surquest.fastapi.utils.catcher import (
27+
from fastapi import FastAPI, Request, Query
28+
29+
# import surquest modules and objects
30+
from surquest.fastapi.utils.route import Route # custom routes for documentation and FavIcon
31+
from surquest.fastapi.utils.GCP.tracer import Tracer
32+
from surquest.fastapi.utils.GCP.logging import Logger
33+
from surquest.fastapi.schemas.responses import Response
34+
from surquest.fastapi.utils.GCP.middleware import LoggingMiddleware
35+
from surquest.fastapi.utils.GCP.catcher import (
2536
catch_validation_exceptions,
2637
catch_http_exceptions,
2738
)
2839

40+
PATH_PREFIX = os.getenv('PATH_PREFIX','')
41+
2942
app = FastAPI(
30-
title="Sample REST API application",
31-
openapi_url="/openapi.json"
43+
title="Exchange Rates ETL",
44+
openapi_url=F"{PATH_PREFIX}/openapi.json"
3245
)
3346

3447
# add middleware
35-
app.add_middleware(BasicMiddleware) # this middleware writes request and response to the log
48+
app.add_middleware(LoggingMiddleware)
3649

3750
# exception handlers
3851
app.add_exception_handler(HTTPException, catch_http_exceptions)
3952
app.add_exception_handler(RequestValidationError, catch_validation_exceptions)
4053

41-
# custom routes
42-
app.add_api_route(path="/", endpoint=Route.get_documentation, include_in_schema=False)
54+
# custom routes to documentation and favicon
55+
app.add_api_route(path=F"{PATH_PREFIX}/", endpoint=Route.get_documentation, include_in_schema=False)
56+
app.add_api_route(path=PATH_PREFIX, endpoint=Route.get_favicon, include_in_schema=False)
57+
58+
# custom route to illustrate logging and tracing
59+
@app.get(F"{PATH_PREFIX}/users")
60+
async def get_users(
61+
age: int = Query(
62+
default=18,
63+
description="Minimal age of the user",
64+
example=30,
65+
66+
),
67+
):
68+
69+
with Tracer.start_span("Generate users"):
70+
71+
users = [
72+
{"name": "John Doe", "age": 30, "email": "[email protected]"},
73+
{"name": "Will Smith", "age": 42, "email": "[email protected]"}
74+
]
75+
76+
Logger.info('Found %s users', len(users), extra={"users": users})
77+
78+
with Tracer.start_span("Filtering users"):
79+
80+
output = []
81+
excluded = []
82+
Logger.debug(F"Filtering users by age > {age}")
83+
84+
for user in users:
85+
86+
if user["age"] > age:
87+
output.append(user)
88+
else:
89+
excluded.append(user)
90+
91+
Logger.debug(
92+
'Number of excluded users: %s', len(excluded),
93+
extra={"excluded": excluded}
94+
)
95+
96+
return Response.set(data=output)
97+
```
98+
99+
The endpoint `/users` will return the following standard response:
100+
101+
```json
102+
{
103+
"info": {
104+
"status": "success"
105+
},
106+
"data": [
107+
{
108+
"name": "John Doe",
109+
"age": 30,
110+
"email": "[email protected]"
111+
},
112+
{
113+
"name": "Will Smith",
114+
"age": 42,
115+
"email": "[email protected]"
116+
}
117+
]
118+
}
43119
```
44120

121+
and the logs will are available in Google Cloud Platform console within Stackdriver Logging:
122+
123+
![Log Entries](https://github.com/surquest/python-fastapi-utils/blob/main/assets/img/logs.png?raw=true)
124+
125+
as well as the traces are available in Google Cloud Platform console within Stackdriver Trace:
126+
127+
![Trace](https://github.com/surquest/python-fastapi-utils/blob/main/assets/img/trace.png?raw=true)
128+
45129

46130
# Local development
47131

assets/img/logs.png

42.5 KB
Loading

assets/img/trace.png

64.8 KB
Loading

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "surquest-fastapi-utils"
7-
version = "0.0.1rc9"
7+
version = "0.1.0rc1"
88
description = "This project provides collection of utilities for FastAPI framework as: Catcher, Middleware, etc."
99
authors = [
1010
{name= "Michal Švarc", email= "[email protected]"}
1111
]
1212
readme = "README.md"
1313
dependencies = [
14-
"fastapi >= 0.91.0",
15-
"surquest-GCP-logger >= 0.1.0",
16-
"surquest-fastapi-schemas >= 0.0.1rc8"
14+
"fastapi >= 0.81.0",
15+
"google-cloud-logging >= 3.1.0",
16+
"opentelemetry-exporter-gcp-trace ~= 1.4.0",
1717
]
1818

1919
[project.optional-dependencies]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .info import InfoSuccess, InfoWarning, InfoError
2+
from .message import Message
3+
from .responses import Success, Warnings, Errors, Response, Responses
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from pydantic import BaseModel
2+
3+
class Base(BaseModel):
4+
"""Base class for all responses:
5+
6+
This class ensures that all undefined fields are excluded from the response
7+
8+
"""
9+
def dict(self, *args, **kwargs):
10+
if kwargs:
11+
kwargs["exclude_none"] = True
12+
13+
return BaseModel.dict(self, *args, **kwargs)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from .base import Base
2+
from typing import List, Optional
3+
from .status import Status
4+
from .message import Message
5+
from .metadata import Metadata
6+
7+
8+
class InfoSuccess(Base):
9+
status: str = Status.success
10+
metadata: Optional[Metadata] = None
11+
12+
13+
class InfoWarning(Base):
14+
status: str = Status.warning
15+
metadata: Optional[Metadata] = None
16+
warnings: List[Message] = []
17+
18+
19+
class InfoError(Base):
20+
status: str = Status.error
21+
warnings: Optional[List[Message]] = None
22+
errors: List[Message] = []
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from pydantic import Field
2+
from .base import Base
3+
from typing import Optional
4+
5+
6+
class Message(Base):
7+
8+
msg: str = Field(...)
9+
type: Optional[str] = None
10+
loc: Optional[list] = None
11+
ctx: Optional[dict] = None
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from pydantic import Field
2+
from .base import Base
3+
4+
class Metadata(Base):
5+
6+
offset: int = Field(
7+
default=0,
8+
title="Offset",
9+
description="Number of records to skip from the beginning",
10+
ge=0,
11+
example=0,
12+
)
13+
14+
limit: int = Field(
15+
default=0,
16+
title="Offset",
17+
description="Number of records to skip from the beginning",
18+
ge=0,
19+
example=0,
20+
)
21+
22+
count: int = Field(
23+
...,
24+
title="Count of records",
25+
description="Count of records returned by the call",
26+
example=391,
27+
ge=0,
28+
)
29+
30+
total: int = Field(
31+
...,
32+
title="Total records",
33+
description="Total number of records in the database",
34+
example=18301,
35+
ge=0,
36+
)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from .base import Base
2+
from .info import InfoSuccess, InfoWarning, InfoError
3+
from typing import Union, List, Dict, Optional
4+
from starlette.responses import JSONResponse, Response
5+
from fastapi.encoders import jsonable_encoder
6+
7+
8+
class Success(Base):
9+
info: InfoSuccess = InfoSuccess()
10+
data: Optional[Union[List, Dict]]= None
11+
12+
@classmethod
13+
def set(cls, data, metadata=None):
14+
return cls(
15+
info=InfoSuccess(metadata=metadata),
16+
data=data
17+
)
18+
19+
20+
class Warnings(Base):
21+
22+
info: InfoWarning = InfoWarning()
23+
data: Optional[Union[List, Dict]] = None
24+
25+
@classmethod
26+
def set(cls, warnings, data, metadata=None):
27+
28+
return cls(
29+
info=InfoWarning(warnings=warnings, metadata=metadata),
30+
data=data
31+
)
32+
33+
34+
class Errors(Base):
35+
info: InfoError = InfoError()
36+
37+
@classmethod
38+
def set(cls, errors, warnings=None):
39+
return cls(
40+
info=InfoError(
41+
warnings=warnings,
42+
errors=errors
43+
)
44+
)
45+
46+
class Response:
47+
48+
@classmethod
49+
def set(
50+
cls,
51+
status_code=None,
52+
data=None,
53+
metadata=None,
54+
warnings=None,
55+
errors=None
56+
):
57+
58+
status_code = cls.get_status_code(status_code, warnings, errors)
59+
60+
if errors is not None:
61+
62+
return JSONResponse(
63+
status_code=status_code,
64+
content=jsonable_encoder(
65+
Errors.set(
66+
errors=errors,
67+
warnings=warnings
68+
)
69+
)
70+
)
71+
if errors is None and warnings is not None:
72+
73+
return JSONResponse(
74+
status_code=status_code,
75+
content=jsonable_encoder(
76+
Warnings.set(
77+
warnings=warnings,
78+
data=data,
79+
metadata=metadata
80+
)
81+
)
82+
)
83+
84+
return JSONResponse(
85+
status_code=status_code,
86+
content=jsonable_encoder(
87+
Success.set(
88+
data=data,
89+
metadata=metadata
90+
)
91+
)
92+
)
93+
94+
@staticmethod
95+
def get_status_code(status_code, warnings, errors):
96+
97+
if isinstance(status_code, int):
98+
return status_code
99+
100+
if errors is not None:
101+
return 500
102+
103+
if errors is None and warnings is not None:
104+
return 299
105+
106+
return 200
107+
108+
class Responses:
109+
110+
@classmethod
111+
def get(cls):
112+
113+
return {
114+
200: {"model": Success},
115+
299: {"model": Warnings},
116+
400: {"model": Errors},
117+
422: {"model": Errors},
118+
500: {"model": Errors},
119+
}

0 commit comments

Comments
 (0)