Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(python): FastAPI support #1167

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ jobs:
- run: make test-metrics-python-django-asgi
- run: make test-metrics-python-flask
- run: make test-webhooks-python-flask
- run: make test-metrics-python-fastapi
- run: make test-webhooks-python-fastapi

- name: Cleanup
if: always()
Expand Down
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ test-webhooks-python-flask: ## Run webhooks tests against the Python SDK + Flask
SUPPORTS_HASHING=true npm run test:integration-webhooks || make cleanup-failure
@make cleanup

test-metrics-python-fastapi: ## Run Metrics tests against the Python SDK + FastAPI
docker compose up --build --detach integration_python_fastapi_metrics
SUPPORTS_HASHING=true npm run test:integration-metrics || make cleanup-failure
@make cleanup

test-webhooks-python-fastapi: ## Run webhooks tests against the Python SDK + FastAPI
docker compose up --build --detach integration_python_fastapi_webhooks
SUPPORTS_HASHING=true npm run test:integration-webhooks || make cleanup-failure
@make cleanup

##
## Ruby
##
Expand Down
20 changes: 20 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,26 @@ services:
environment:
<<: *server-config

integration_python_fastapi_metrics:
build:
context: .
dockerfile: ./test/integrations/python/fastapi.Dockerfile
ports:
- 8000:8000
extra_hosts: *default-extra_hosts
environment:
<<: *server-config

integration_python_fastapi_webhooks:
build:
context: .
dockerfile: ./test/integrations/python/fastapi-webhooks.Dockerfile
ports:
- 8000:8000
extra_hosts: *default-extra_hosts
environment:
<<: *server-config

#
# Ruby
#
Expand Down
6 changes: 6 additions & 0 deletions packages/python/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ serve-metrics-flask: ## Start the local Flask server to test Metrics
serve-webhooks-flask: ## Start the local Flask server to test webhooks
README_API_KEY=$(API_KEY) python3 examples/flask/webhooks.py

serve-metrics-fastapi: ## Start the local FastAPI server to test Metrics
cd examples/fastapi && README_API_KEY=$(API_KEY) uvicorn app:app --reload

serve-webhooks-fastapi: ## Start the local FastAPI server to test webhooks
cd examples/fastapi && README_API_KEY=$(API_KEY) uvicorn webhooks:app --reload

test: ## Run unit tests
pytest

Expand Down
48 changes: 48 additions & 0 deletions packages/python/examples/fastapi/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import os
import sys

from fastapi import FastAPI
from readme_metrics import MetricsApiConfig
from readme_metrics.fastapi import ReadMeMetricsMiddleware


if os.getenv("README_API_KEY") is None:
sys.stderr.write("Missing `README_API_KEY` environment variable")
sys.stderr.flush()
sys.exit(1)

app = FastAPI()


# pylint: disable=W0613
def grouping_function(request):
return {
# User's API Key
"api_key": "owlbert-api-key",
# Username to show in the dashboard
"label": "Owlbert",
# User's email address
"email": "[email protected]",
}


config = MetricsApiConfig(
api_key=os.getenv("README_API_KEY"),
grouping_function=grouping_function,
background_worker_mode=False,
buffer_length=1,
timeout=5,
)

# Add middleware with configuration using a lambda
app.add_middleware(ReadMeMetricsMiddleware, config=config)


@app.get("/")
def read_root():
return {"message": "hello world"}


@app.post("/")
def post_root():
return ""
14 changes: 14 additions & 0 deletions packages/python/examples/fastapi/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
annotated-types==0.7.0
anyio==4.8.0
click==8.1.8
exceptiongroup==1.2.2
fastapi==0.115.6
h11==0.14.0
idna==3.10
pydantic==2.10.4
pydantic_core==2.27.2
../../
sniffio==1.3.1
starlette==0.41.3
typing_extensions==4.12.2
uvicorn==0.34.0
45 changes: 45 additions & 0 deletions packages/python/examples/fastapi/webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import os
import sys

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from readme_metrics.VerifyWebhook import VerifyWebhook


if os.getenv("README_API_KEY") is None:
sys.stderr.write("Missing `README_API_KEY` environment variable")
sys.stderr.flush()
sys.exit(1)

app = FastAPI()

# Your ReadMe secret
secret = os.getenv("README_API_KEY")


@app.post("/webhook")
async def webhook(request: Request):
# Verify the request is legitimate and came from ReadMe.
signature = request.headers.get("readme-signature", None)

try:
body = await request.json()
VerifyWebhook(body, signature, secret)
except Exception as error:
return JSONResponse(
status_code=401,
headers={"Content-Type": "application/json; charset=utf-8"},
content={"error": str(error)},
Dismissed Show dismissed Hide dismissed
)

# Fetch the user from the database and return their data for use with OpenAPI variables.
# user = User.objects.get(email__exact=request.values.get("email"))
return JSONResponse(
status_code=200,
headers={"Content-Type": "application/json; charset=utf-8"},
content={
"petstore_auth": "default-key",
"basic_auth": {"user": "user", "pass": "pass"},
},
)
14 changes: 7 additions & 7 deletions packages/python/readme_metrics/PayloadBuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ def _build_request_payload(self, request) -> dict:
dict: Wrapped request payload
"""
headers = self.redact_dict(request.headers)

queryString = parse.parse_qsl(self._get_query_string(request))

content_type = self._get_content_type(headers)
Expand All @@ -187,9 +186,9 @@ def _build_request_payload(self, request) -> dict:
request, "rm_content_length", None
):
if content_type == "application/x-www-form-urlencoded":
# Flask creates `request.form` but Django puts that data in `request.body`, and
# then our `request.rm_body` store, instead.
if hasattr(request, "form"):
# Flask creates `request.form` and does not have a `body` property but Django
# puts that data in `request.body`, and then our `request.rm_body` store, instead.
if hasattr(request, "form") and not hasattr(request, "body"):
params = [
# Reason this is not mixed in with the `rm_body` parsing if we don't have
# `request.form` is that if we attempt to do `str(var, 'utf-8)` on data
Expand Down Expand Up @@ -219,8 +218,9 @@ def _build_request_payload(self, request) -> dict:

headers = dict(headers)

if "Authorization" in headers:
headers["Authorization"] = mask(headers["Authorization"])
for key in ["Authorization", "authorization"]:
if key in headers:
headers[key] = mask(headers[key])

if hasattr(request, "environ"):
http_version = request.environ["SERVER_PROTOCOL"]
Expand Down Expand Up @@ -324,7 +324,7 @@ def _build_base_url(self, request):
query_string = self._get_query_string(request)
if hasattr(request, "base_url"):
# Werkzeug request objects already have exactly what we need
base_url = request.base_url
base_url = str(request.base_url)
if len(query_string) > 0:
base_url += f"?{query_string}"
return base_url
Expand Down
90 changes: 90 additions & 0 deletions packages/python/readme_metrics/fastapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from datetime import datetime
import time

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

from readme_metrics import MetricsApiConfig
from readme_metrics.Metrics import Metrics
from readme_metrics.ResponseInfoWrapper import ResponseInfoWrapper


class ReadMeMetricsMiddleware(BaseHTTPMiddleware):
def __init__(self, app, config: MetricsApiConfig):
super().__init__(app)
self.config = config
self.metrics_core = Metrics(config)

async def _safe_retrieve_body(self, request):
# Safely retrieve the request body.
try:
body = await request.body()
return body
except Exception as e:
self.config.LOGGER.exception(e)
return None

async def _read_response_body(self, response):
# Reads and decodes the response body.
try:
body_chunks = []
async for chunk in response.body_iterator:
body_chunks.append(chunk)
response.body_iterator = iter(body_chunks)
encoded_body = b"".join(body_chunks)

try:
return encoded_body.decode("utf-8")
except UnicodeDecodeError:
return "[NOT VALID UTF-8]"
except Exception as e:
self.config.LOGGER.exception(e)
return ""

async def preamble(self, request):
# Initialize metrics-related attributes on the request object.
try:
request.rm_start_dt = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
request.rm_start_ts = int(time.time() * 1000)

content_length = request.headers.get("Content-Length")
body = await self._safe_retrieve_body(request)

if content_length or body:
request.rm_content_length = content_length or "0"
request.rm_body = body or ""
except Exception as e:
self.config.LOGGER.exception(e)

async def dispatch(self, request: Request, call_next):
if request.method == "OPTIONS":
return await call_next(request)

await self.preamble(request)

response = None
response_body = None
try:
response = await call_next(request)
response_body = await self._read_response_body(response)

response_info = ResponseInfoWrapper(
headers=response.headers,
status=response.status_code,
content_type=response.headers.get("Content-Type"),
content_length=response.headers.get("Content-Length"),
body=response_body,
)

self.metrics_core.process(request, response_info)

except Exception as e:
self.config.LOGGER.exception(e)

return Response(
content=response_body,
status_code=response.status_code,
headers=response.headers,
media_type=response.media_type,
)
86 changes: 86 additions & 0 deletions packages/python/readme_metrics/tests/fastapi_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from datetime import datetime, timedelta
import time
from unittest.mock import Mock, AsyncMock
import pytest
from fastapi import Response

from readme_metrics import MetricsApiConfig
from readme_metrics.ResponseInfoWrapper import ResponseInfoWrapper
from readme_metrics.fastapi import ReadMeMetricsMiddleware

mock_config = MetricsApiConfig(
"README_API_KEY",
lambda req: {"id": "123", "label": "testuser", "email": "[email protected]"},
buffer_length=1000,
)


class TestFastAPIMiddleware:
def setup_middleware(self, is_async=False):
app = AsyncMock if is_async else Mock()

middleware = ReadMeMetricsMiddleware(app, config=mock_config)
middleware.metrics_core = Mock()
return middleware

def validate_metrics(self, middleware, request):
assert hasattr(request, "rm_start_dt")
req_start_dt = datetime.strptime(request.rm_start_dt, "%Y-%m-%dT%H:%M:%SZ")
current_dt = datetime.utcnow()
assert abs(current_dt - req_start_dt) < timedelta(seconds=1)

assert hasattr(request, "rm_start_ts")
req_start_millis = request.rm_start_ts
current_millis = time.time() * 1000.0
assert abs(current_millis - req_start_millis) < 1000.00

middleware.metrics_core.process.assert_called_once()
call_args = middleware.metrics_core.process.call_args
assert len(call_args[0]) == 2
assert call_args[0][0] == request
assert isinstance(call_args[0][1], ResponseInfoWrapper)
assert call_args[0][1].headers.get("x-header") == "X Value!"
assert (
getattr(request, "rm_content_length") == request.headers["Content-Length"]
)

@pytest.mark.asyncio
async def test(self):
middleware = self.setup_middleware()

request = Mock()
request.headers = {"Content-Length": "123"}

call_next = AsyncMock()
call_next.return_value = Response(content="", headers={"X-Header": "X Value!"})

await middleware.dispatch(request, call_next)

self.validate_metrics(middleware, request)

@pytest.mark.asyncio
async def test_missing_content_length(self):
middleware = self.setup_middleware()

request = AsyncMock()
request.headers = {}

call_next = AsyncMock()
call_next.return_value = Response(content="")

await middleware.dispatch(request, call_next)

assert getattr(request, "rm_content_length") == "0"

@pytest.mark.asyncio
async def test_options_request(self):
middleware = self.setup_middleware()

request = AsyncMock()
request.method = "OPTIONS"

call_next = AsyncMock()

await middleware.dispatch(request, call_next)

assert not middleware.metrics_core.process.called
Loading
Loading