Skip to content
Merged
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
17 changes: 14 additions & 3 deletions src/quartz_api/cmd/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,14 @@ def _create_v1_app(
) -> FastAPI:
"""Create and configure the v1 FastAPI sub-application."""
v1_mod = importlib.import_module(service.__name__ + ".v1")
deployment = v1_mod.country_config
# V1_COUNTRIES and V1_STAGE are read in country_config, not through `conf`: the
# OpenAPI enums are built when that module is imported, before conf is available.
log.info(
"v1 serving countries %s at stage '%s'",
sorted(deployment.COUNTRIES),
deployment.DEPLOYMENT_STAGE,
)

scalar_auth: dict = {}
if auth_openapi_config:
Expand Down Expand Up @@ -392,7 +400,8 @@ def _create_v1_app(
v1_app.add_exception_handler(grpc.aio.AioRpcError, _grpc_exception_handler)
v1_app.add_middleware(SlowAPIMiddleware)
v1_app.include_router(v1_mod.router)
v1_app.openapi = lambda: _custom_openapi(
# FastAPI's documented way to install a custom schema; ty sees a method overwritten.
v1_app.openapi = lambda: _custom_openapi( # ty: ignore[invalid-assignment]
v1_app, auth_openapi_config, contact=_V1_CONTACT,
)

Expand Down Expand Up @@ -725,7 +734,8 @@ async def v1_scalar_docs(request: Request) -> HTMLResponse:
}
""",
)
html = page.body.decode()
# Response.body is typed bytes | memoryview; HTMLResponse always gives bytes.
html = bytes(page.body).decode()
if "</body>" not in html:
return page
return HTMLResponse(
Expand Down Expand Up @@ -984,7 +994,8 @@ def redoc_html() -> FileResponse:
server.description = description

# Customize the OpenAPI schema (after auth config is resolved)
server.openapi = lambda: _custom_openapi(server)
# FastAPI's documented way to install a custom schema; ty sees a method overwritten.
server.openapi = lambda: _custom_openapi(server) # ty: ignore[invalid-assignment]

# Mount v1 as a sub-app (after auth config is resolved so v1 gets OAuth2 config)
if "v1" in conf.get_string("api.routers").split(","):
Expand Down
7 changes: 7 additions & 0 deletions src/quartz_api/cmd/server.conf
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ api {
host_url = ${?HOST_URL}
workers = 1
workers = ${?NUM_WORKERS}
// v1 deployment scope is set by two env vars that are deliberately absent here:
// service/v1/country_config.py reads them directly, because the OpenAPI enums are
// built when it is imported, before this config is loaded. Declaring them here too
// would be a second copy of the same value.
// V1_COUNTRIES comma separated country codes to serve, e.g. "GB,NL". Empty serves all.
// V1_STAGE "production" (default) hides catalogue entries tagged
// stage="development"; "development" shows them. Matches api.environment.
}

// The backend to use for the service
Expand Down
89 changes: 89 additions & 0 deletions src/quartz_api/cmd/test_main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
"""Tests for the server assembly in main.py."""

import json
import os
import subprocess
import sys

import grpc
import pytest
from fastapi import FastAPI
Expand Down Expand Up @@ -66,3 +71,87 @@ def test_grpc_error_repr_never_reaches_the_caller() -> None:
resp = TestClient(_app_raising(grpc.StatusCode.NOT_FOUND)).get("/boom")
assert "debug_error_string" not in resp.text
assert "AioRpcError" not in resp.text


# The v1 country filter runs when country_config is imported, and main.py builds the
# server on import, so each case needs its own interpreter.
_BOOT_PROBE = """
import json
from fastapi.testclient import TestClient
from quartz_api.cmd.main import server
client = TestClient(server)
spec = client.get("/v1/openapi.json").json()
print(json.dumps({
"enums": sorted({
tuple(sorted(p["schema"]["enum"]))
for path in spec["paths"].values()
for op in path.values()
for p in op.get("parameters", [])
if p["name"] == "country"
}),
"status": {
code: client.get(f"/v1/{code}/solar/region-types").status_code
for code in ("GB", "NL", "DE")
},
}))
"""


def _boot(countries: str, stage: str) -> subprocess.CompletedProcess[str]:
env = {
**os.environ,
"ROUTERS": "v1",
"SOURCE": "dummydb",
"V1_COUNTRIES": countries,
"V1_STAGE": stage,
}
return subprocess.run(
[sys.executable, "-c", _BOOT_PROBE],
env=env,
capture_output=True,
text=True,
check=False,
timeout=60,
)


@pytest.mark.parametrize(
("countries", "stage", "served"),
[
("GB,NL", "production", ["GB", "NL"]),
("NL", "production", ["NL"]),
# DE is development-only, so allowlisting it on production serves nothing extra.
("NL,DE", "production", ["NL"]),
("GB,NL,DE", "development", ["DE", "GB", "NL"]),
],
)
def test_server_boots_serving_only_the_deployment_countries(
countries: str,
stage: str,
served: list[str],
) -> None:
result = _boot(countries, stage)
assert result.returncode == 0, result.stderr
out = json.loads(result.stdout.strip().splitlines()[-1])
assert out["enums"] == [served]
assert out["status"] == {
code: 200 if code in served else 422 for code in ("GB", "NL", "DE")
}


@pytest.mark.parametrize(
("countries", "stage", "message"),
[
("GB,GD", "production", "V1_COUNTRIES has unknown codes ['GD']"),
# The short forms are not accepted; the stage values match api.environment.
("GB", "prod", "V1_STAGE must be one of"),
],
)
def test_server_refuses_to_boot_on_bad_deployment_config(
countries: str,
stage: str,
message: str,
) -> None:
result = _boot(countries, stage)
assert result.returncode != 0
assert message in result.stderr
8 changes: 8 additions & 0 deletions src/quartz_api/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Test-wide setup that has to run before any quartz_api module is imported."""

import os

# country_config filters the v1 catalogue from these at import time. Tests assert on
# every country and model, so a deployment scope in the shell must not leak in.
os.environ.pop("V1_COUNTRIES", None)
os.environ["V1_STAGE"] = "development"
12 changes: 8 additions & 4 deletions src/quartz_api/internal/middleware/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING

from fastapi import FastAPI, Request, Response
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp

if TYPE_CHECKING:
from quartz_api.internal import models
Expand All @@ -14,9 +15,12 @@
class RequestLoggerMiddleware(BaseHTTPMiddleware):
"""Middleware to log API requests to the database."""

def __init__(self, server: FastAPI) -> None:
"""Initialize the middleware with the FastAPI server and database client."""
super().__init__(server)
def __init__(self, app: ASGIApp) -> None:
"""Initialize the middleware.

`add_middleware` passes the wrapped ASGI app, not the FastAPI instance.
"""
super().__init__(app)

async def dispatch(
self,
Expand Down
12 changes: 8 additions & 4 deletions src/quartz_api/internal/middleware/sentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
from collections.abc import Awaitable, Callable

import sentry_sdk
from fastapi import FastAPI, Request, Response
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp

from quartz_api.internal.middleware import auth
from quartz_api.internal.middleware.auth import get_org_id_from_authdata
Expand All @@ -16,11 +17,14 @@ class SentryUserMiddleware(BaseHTTPMiddleware):

def __init__(
self,
server: FastAPI,
app: ASGIApp,
auth_instance: auth.AuthClient | None,
) -> None:
"""Initialize FastAPI server and auth instance."""
super().__init__(server)
"""Initialize the middleware and auth instance.

`add_middleware` passes the wrapped ASGI app, not the FastAPI instance.
"""
super().__init__(app)
self.auth_instance = auth_instance

async def dispatch(
Expand Down
12 changes: 8 additions & 4 deletions src/quartz_api/internal/middleware/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
from typing import Any

import grpc.aio
from fastapi import FastAPI, Request, Response
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from typing_extensions import override

CORR_HEADER = "X-Request-Id"
Expand All @@ -36,9 +37,12 @@ def set_trace_id(trace_id: str) -> None:
class TracerMiddleware(BaseHTTPMiddleware):
"""Middleware to add tracing information to API requests."""

def __init__(self, server: FastAPI) -> None:
"""Initialize the middleware with the FastAPI server and database client."""
super().__init__(server)
def __init__(self, app: ASGIApp) -> None:
"""Initialize the middleware.

`add_middleware` passes the wrapped ASGI app, not the FastAPI instance.
"""
super().__init__(app)

async def dispatch(
self,
Expand Down
Loading
Loading