Repository navigation
feat: add version and environment to /health, and answer HEAD #258
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
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,14 +15,15 @@ | |||||||||||||||||||||||||||||||||||||
| import uvicorn | ||||||||||||||||||||||||||||||||||||||
| from fastapi import Depends, FastAPI | ||||||||||||||||||||||||||||||||||||||
| from fastapi.exceptions import RequestValidationError | ||||||||||||||||||||||||||||||||||||||
| from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, RedirectResponse | ||||||||||||||||||||||||||||||||||||||
| from fastapi.requests import Request | ||||||||||||||||||||||||||||||||||||||
| from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response | ||||||||||||||||||||||||||||||||||||||
| from fastapi.routing import APIRoute | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if TYPE_CHECKING: | ||||||||||||||||||||||||||||||||||||||
| from collections.abc import AsyncGenerator | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| from fastapi.requests import Request | ||||||||||||||||||||||||||||||||||||||
| from fastapi.responses import Response | ||||||||||||||||||||||||||||||||||||||
| from fastapi.response import Response | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+18
to
+26
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Apply this together with the suggestions on lines 338 and 349 (Add suggestion to batch). If only this one is applied, FastAPI can no longer resolve |
||||||||||||||||||||||||||||||||||||||
| from starlette.middleware.base import RequestResponseEndpoint | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| from pydantic import ValidationError | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -43,7 +44,7 @@ | |||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| from app.metrics.middleware import record_request_metrics | ||||||||||||||||||||||||||||||||||||||
| from app.metrics.prometheus import PROMETHEUS_CONTENT_TYPE, MetricsFormat, render_prometheus | ||||||||||||||||||||||||||||||||||||||
| from app.models import MetricsModel, RequestModel, ResponseModel | ||||||||||||||||||||||||||||||||||||||
| from app.models import HealthModel, MetricsModel, RequestModel, ResponseModel | ||||||||||||||||||||||||||||||||||||||
| from app.pesu import PESUAcademy | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| IST = ZoneInfo("Asia/Kolkata") | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -85,6 +86,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: | |||||||||||||||||||||||||||||||||||||
| # Startup | ||||||||||||||||||||||||||||||||||||||
| metrics.increment(LIFESPAN_EVENTS, event="startup") | ||||||||||||||||||||||||||||||||||||||
| logging.info("PESUAuth API startup") | ||||||||||||||||||||||||||||||||||||||
| if os.environ.get("PESU_AUTH_ENVIRONMENT", "development") not in ("development", "staging", "production"): | ||||||||||||||||||||||||||||||||||||||
| raise ValueError("Invaild enivronment value. Value must be development, staging or production") | ||||||||||||||||||||||||||||||||||||||
| app.state.environment = os.environ.get("PESU_AUTH_ENVIRONMENT", "development") | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+89
to
+91
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A few things here:
Suggested change
The test on lines 48-51 of |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| yield | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
@@ -326,19 +330,23 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR | |||||||||||||||||||||||||||||||||||||
| @app.head("/health", include_in_schema=False) | ||||||||||||||||||||||||||||||||||||||
| @app.get( | ||||||||||||||||||||||||||||||||||||||
| "/health", | ||||||||||||||||||||||||||||||||||||||
| response_model=HealthModel, | ||||||||||||||||||||||||||||||||||||||
| response_class=JSONResponse, | ||||||||||||||||||||||||||||||||||||||
| responses=health_docs.response_examples, | ||||||||||||||||||||||||||||||||||||||
| tags=["Monitoring"], | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| async def health() -> JSONResponse: | ||||||||||||||||||||||||||||||||||||||
| async def health(request: Request) -> JSONResponse: | ||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Part of the import fix on lines 18-26:
Suggested change
Apply this together with the suggestions on lines 18-26 and 349 (Add suggestion to batch). |
||||||||||||||||||||||||||||||||||||||
| """Health check endpoint.""" | ||||||||||||||||||||||||||||||||||||||
| logging.debug("Health check requested.") | ||||||||||||||||||||||||||||||||||||||
| return JSONResponse( | ||||||||||||||||||||||||||||||||||||||
| status_code=200, | ||||||||||||||||||||||||||||||||||||||
| headers={"Cache-Control": "no-store"}, | ||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #254 put all of the header logic in
Suggested change
and add Returns:
dict[str, str]: The general security headers, plus `Cache-Control: no-store` for /authenticate and /health.
"""
headers = dict(SECURITY_HEADERS)
if request.url.path in ("/authenticate", "/health"):
headers["Cache-Control"] = "no-store"Also update the |
||||||||||||||||||||||||||||||||||||||
| content={ | ||||||||||||||||||||||||||||||||||||||
| "status": True, | ||||||||||||||||||||||||||||||||||||||
| "message": "ok", | ||||||||||||||||||||||||||||||||||||||
| "timestamp": datetime.datetime.now(IST).isoformat(), | ||||||||||||||||||||||||||||||||||||||
| "version": version("pesu-auth"), | ||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||||
| "environment": request.app.state.environment, | ||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Part of the import fix on lines 18-26:
Suggested change
Apply this together with the suggestions on lines 18-26 and 338 (Add suggestion to batch). |
||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,23 @@ | ||
| """Custom docs for the /health PESUAuth endpoint.""" | ||
|
|
||
| from app.docs.base import ApiDocs | ||
| from app.models import ResponseModel | ||
| from app.models import HealthModel, ResponseModel | ||
|
|
||
| health_docs = ApiDocs( | ||
| request_examples={}, | ||
| response_examples={ | ||
| 200: { | ||
| "description": "Successful Health Check.", | ||
| "model": ResponseModel, | ||
| "model": HealthModel, | ||
| "content": { | ||
| "application/json": { | ||
| "example": {"status": True, "message": "ok", "timestamp": "2024-07-28T22:30:10.103368+05:30"} | ||
| "example": { | ||
| "status": True, | ||
| "message": "ok", | ||
| "timestamp": "2024-07-28T22:30:10.103368+05:30", | ||
| "version": "5.0.0", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this PR bumps the package to 5.5.0, while /health returns the installed package version. Please update the example to match the current version |
||
| "environment": "development", | ||
| } | ||
| } | ||
| }, | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,53 @@ | ||||||||||||||||||
| """Model representing response from health endpoint.""" | ||||||||||||||||||
|
|
||||||||||||||||||
| from datetime import datetime | ||||||||||||||||||
| from typing import Literal | ||||||||||||||||||
|
|
||||||||||||||||||
| from pydantic import BaseModel, ConfigDict, Field | ||||||||||||||||||
| from pydantic.alias_generators import to_camel | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class HealthModel(BaseModel): | ||||||||||||||||||
| """Model representing response from health endpoint.""" | ||||||||||||||||||
|
|
||||||||||||||||||
| model_config = ConfigDict(strict=True, alias_generator=to_camel, populate_by_name=True) | ||||||||||||||||||
|
|
||||||||||||||||||
| status: bool = Field( | ||||||||||||||||||
| ..., | ||||||||||||||||||
| title="Health status", | ||||||||||||||||||
| description="indicates whether the service is alive", | ||||||||||||||||||
| json_schema_extra={"example": True}, | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| message: str = Field( | ||||||||||||||||||
| ..., | ||||||||||||||||||
| title="Health message", | ||||||||||||||||||
| description="A string indicating the service's health", | ||||||||||||||||||
| json_schema_extra={"example": "ok"}, | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| timestamp: datetime = Field( | ||||||||||||||||||
| ..., | ||||||||||||||||||
| # Relaxed from the model-wide strict=True for this field alone. The API builds this model | ||||||||||||||||||
| # from a datetime but serializes an ISO string onto the wire, so a strict model could not | ||||||||||||||||||
| # parse its own responses -- which made the published schema unusable to a client wanting | ||||||||||||||||||
| # to validate with it, and forced the documentation tests into JSON mode to compensate. | ||||||||||||||||||
| strict=False, | ||||||||||||||||||
| title="timestamp", | ||||||||||||||||||
| description="returns the timestamp of when /health was used", | ||||||||||||||||||
|
Comment on lines
+36
to
+37
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Titles and descriptions here are cased inconsistently ("Health status" vs "timestamp" and "version"), and this one reads oddly. Match
Suggested change
The same goes for the other fields: e.g. "Version" for |
||||||||||||||||||
| json_schema_extra={"example": "2024-07-28T22:30:10.103368+05:30"}, | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| version: str = Field( | ||||||||||||||||||
| ..., | ||||||||||||||||||
| title="version", | ||||||||||||||||||
| description="The version of pesu-auth that is running", | ||||||||||||||||||
| json_schema_extra={"example": "5.0.0"}, | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| environment: Literal["development", "staging", "production"] = Field( | ||||||||||||||||||
| default="development", | ||||||||||||||||||
| title="environment", | ||||||||||||||||||
| description="an environment variable", | ||||||||||||||||||
| json_schema_extra={"example": "developement"}, | ||||||||||||||||||
|
Comment on lines
+49
to
+52
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A few problems with this field:
Suggested change
|
||||||||||||||||||
| ) | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| [project] | ||
| name = "pesu-auth" | ||
| version = "5.4.0" | ||
| version = "5.5.0" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The bump to 5.5.0 is right, but |
||
| description = "A simple API to authenticate PESU credentials using PESU Academy." | ||
| readme = "README.md" | ||
| requires-python = ">=3.14" | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -110,7 +110,6 @@ def check(profile, fields): | |||||||
| assert list(profile) == [field for field in PROFILE_VARIABLES if field in fields], "the fields are out of order" | ||||||||
| mismatched = [field for field in fields if profile[field] != expected_profile[field]] | ||||||||
| assert not mismatched, f"these fields do not match their TEST_* values: {mismatched}" | ||||||||
|
|
||||||||
| return check | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This blank line deletion isn't related to this issue. Please put it back:
Suggested change
|
||||||||
|
|
||||||||
|
|
||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -528,7 +528,7 @@ def assert_security_headers(response): | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| def test_integration_security_headers_on_general_endpoints(client): | ||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removing
Suggested change
|
||||||||||||||||||||||
| """General endpoints must carry standard security headers and not carry Cache-Control: no-store.""" | ||||||||||||||||||||||
| for path in ["/", "/health", "/metrics"]: | ||||||||||||||||||||||
| for path in ["/", "/metrics"]: | ||||||||||||||||||||||
| response = client.get(path) | ||||||||||||||||||||||
| assert response.status_code == 200 | ||||||||||||||||||||||
| assert_security_headers(response) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,51 @@ | ||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||
| from importlib.metadata import version | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import pytest | ||||||||||||||||||||||||||||||||||
| from fastapi.testclient import TestClient | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from app.app import app, lifespan | ||||||||||||||||||||||||||||||||||
| from app.models import HealthModel | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+1
to
+8
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ruff doesn't check
Suggested change
For the rest, run |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @pytest.fixture() | ||||||||||||||||||||||||||||||||||
| def client(): | ||||||||||||||||||||||||||||||||||
| with TestClient(app, raise_server_exceptions=False) as client: | ||||||||||||||||||||||||||||||||||
| yield client | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def test_version(client): | ||||||||||||||||||||||||||||||||||
| response = client.get("/health") | ||||||||||||||||||||||||||||||||||
| if response.status_code == 200: | ||||||||||||||||||||||||||||||||||
| json_data = response.json() | ||||||||||||||||||||||||||||||||||
| actual_version = json_data["version"] | ||||||||||||||||||||||||||||||||||
| assert actual_version == version("pesu-auth") | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+20
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def test_head_method_for_health(client): | ||||||||||||||||||||||||||||||||||
| head_response = client.head("/health") | ||||||||||||||||||||||||||||||||||
| assert head_response.status_code == 200 | ||||||||||||||||||||||||||||||||||
| assert head_response.text == "" | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def test_get_cache_no_store_header(client): | ||||||||||||||||||||||||||||||||||
| get_response = client.get("/health") | ||||||||||||||||||||||||||||||||||
| assert get_response.headers.get("Cache-Control") == "no-store" | ||||||||||||||||||||||||||||||||||
| def test_head_cache_no_store_header(client): | ||||||||||||||||||||||||||||||||||
| head_response = client.head("/health") | ||||||||||||||||||||||||||||||||||
| assert head_response.headers.get("Cache-Control") == "no-store" | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @pytest.mark.parametrize("valid_cases", ("development","production","staging")) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def test_valid_cases_for_PESU_AUTH_ENVIRONMENT(monkeypatch, valid_cases): | ||||||||||||||||||||||||||||||||||
| monkeypatch.setenv("PESU_AUTH_ENVIRONMENT",valid_cases) | ||||||||||||||||||||||||||||||||||
| assert os.environ.get("PESU_AUTH_ENVIRONMENT") == valid_cases | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+35
to
+39
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test never starts the app: it sets the variable and then checks that
Suggested change
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def test_default_fallback_PESU_AUTH_ENVIRONMENT(): | ||||||||||||||||||||||||||||||||||
| assert app.state.environment == "development" | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+41
to
+42
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This only passes because an earlier test's lifespan has already set
Suggested change
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @pytest.mark.parametrize("invalid_cases", ("","prod","Development")) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def test_invalid_cases_for_PESU_AUTH_ENVIRONMENT(monkeypatch, invalid_cases): | ||||||||||||||||||||||||||||||||||
| monkeypatch.setenv("PESU_AUTH_ENVIRONMENT", invalid_cases) | ||||||||||||||||||||||||||||||||||
| with pytest.raises(ValueError) as exc_info: | ||||||||||||||||||||||||||||||||||
| with TestClient(app): | ||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||
| assert "Invaild enivronment value. Value must be development, staging or production" in str(exc_info.value) | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+48
to
+51
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Goes with the suggestion on lines 89-91 of
Suggested change
|
||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,7 +18,7 @@ | |
| ProfileParseError, | ||
| UpstreamError, | ||
| ) | ||
| from app.models import MetricsModel, ProfileModel, RequestModel, ResponseModel | ||
| from app.models import HealthModel, MetricsModel, ProfileModel, RequestModel, ResponseModel | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
body = client.get("/health").json()
assert HealthModel.model_validate(body).status is True
assert HealthModel.model_validate_json(json.dumps(body)).status is True with pytest.raises(Exception, match="status"):
HealthModel.model_validate({**body, "status": "not-a-bool"}) |
||
| from app.pesu import PESUAcademy | ||
|
|
||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issue asks for a commented-out line. It also shouldn't go at the end of the TEST_* profile block. That file's header says the application never reads it, so give it its own section that says the app reads it from its environment: