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
16 changes: 15 additions & 1 deletion src/quartz_api/cmd/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,21 @@ async def v1_scalar_docs(request: Request) -> HTMLResponse:
# already allow as a callback, so it needs no new registration.
page_auth = copy.deepcopy(scalar_auth)
if page_auth:
redirect_uri = str(request.url.replace(query="", fragment=""))
# X-Forwarded-Proto, not request.url.scheme. Behind the load balancer TLS
# is terminated upstream and the app is spoken to over http, so the scheme
# on the request is http even though the browser is on https. gunicorn's
# UvicornWorker only honours the forwarded header from `forwarded_allow_ips`,
# which defaults to loopback, so it is ignored in a deployment and correct
# by accident locally, where there is no proxy at all.
#
# The value has to be the URL the browser is actually on: Auth0 matches it
# against the registered callback, and it is what the token exchange is
# checked against.
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0]
page_url = request.url.replace(query="", fragment="")
if forwarded_proto in ("http", "https"):
page_url = page_url.replace(scheme=forwarded_proto)
redirect_uri = str(page_url)
# Merged into the flow rather than assigned over it: the same block carries
# the client id and the pre-ticked scopes, and replacing it drops both.
flow = page_auth["securitySchemes"]["oauth2"]["flows"]["authorizationCode"]
Expand Down
11 changes: 7 additions & 4 deletions src/quartz_api/internal/service/v1/endpoint_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,10 +470,13 @@ class ForecastValue(BaseModel):
metadata: dict | None = Field(
default=None,
description=(
"Passthrough of the forecaster's own metadata, including when each input "
"data source was last ingested. Keys vary by model and are not a stable "
"contract, and will be replaced by a typed structure once all models run "
"through one pipeline. Returned only when `detail=full`."
"The forecaster's own metadata, including when each input data source was "
"last ingested. Keys vary by model and are not a stable contract, and will "
"be replaced by a typed structure once all models run through one pipeline. "
"Passed through as the forecaster wrote it, with two exceptions: "
"`app_version` is parsed from the string the pipeline emits into an object, "
"and any model name is given as the name this API uses for it rather than "
"the platform's internal one. Returned only when `detail=full`."
),
)

Expand Down
59 changes: 58 additions & 1 deletion src/quartz_api/internal/service/v1/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from .auth_scopes import ALL_COUNTRY_PERMISSIONS
from .country_config import (
COUNTRIES,
CountryConfig,
ForecastModel,
RegionTypeConfig,
Expand Down Expand Up @@ -594,6 +595,62 @@ def latest_capacity(values: list) -> float:
return max(values, key=lambda v: v.valid_timestamp).capacity_kilowatts


def _build_internal_to_api_model_names() -> dict[str, str]:
"""Map every internal DP forecaster name to the name the API exposes it under.

Derived from the model registry rather than written out, so a model added to
`country_config` is covered without a second list to keep in step. Both the plain
and `_adjust` internal names map to the same API name, which is what the adjuster
being a parameter means.
"""
mapping: dict[str, str] = {}
for cfg in COUNTRIES.values():
for rt in cfg.region_types:
for fm in rt.forecast_models:
for internal in (fm.name, fm.adjust_name):
if internal:
mapping[internal] = fm.api_name
return mapping


_INTERNAL_TO_API_MODEL_NAMES = _build_internal_to_api_model_names()


def _translate_model_names_in(metadata: dict) -> dict:
"""Translate the model names in one dict, recursing into what it holds."""
renamed = {k: _INTERNAL_TO_API_MODEL_NAMES.get(k, k) for k in metadata}
# Two internal names can share one API name (a model and its `_adjust` variant).
# Renaming both would drop one entry, so where a target is not unique within
# this dict, those keys keep the names the forecaster gave them.
counts: dict[str, int] = {}
for target in renamed.values():
counts[target] = counts.get(target, 0) + 1
return {
(target if counts[target] == 1 else key): _translate_model_names(val)
for (key, val), target in zip(metadata.items(), renamed.values(), strict=True)
}


def _translate_model_names(value: object) -> object:
"""Rewrite internal forecaster names to their API names, anywhere in the metadata.

The forecaster writes its own metadata, so it uses the platform's names: `blend`'s
`app_version` is keyed by the models that contributed to it, under names like
`pvnet_day_ahead`. Those are not names a caller can pass back to us, which is the
same leak as naming the internal forecaster in an error.

Applied to keys and to string values, and only on an exact match against a known
name, so anything else passes through untouched.
"""
if isinstance(value, dict):
return _translate_model_names_in(value)
if isinstance(value, list):
return [_translate_model_names(v) for v in value]
if isinstance(value, str):
return _INTERNAL_TO_API_MODEL_NAMES.get(value, value)
return value


def parse_forecast_metadata(metadata: dict) -> dict | None:
"""Return the forecaster's metadata dict with `app_version` parsed into an object.

Expand All @@ -610,7 +667,7 @@ def parse_forecast_metadata(metadata: dict) -> dict | None:
if isinstance(raw, str):
with contextlib.suppress(json.JSONDecodeError):
parsed["app_version"] = json.loads(raw)
return parsed
return _translate_model_names_in(parsed)


def plevel_sort_key(name: str) -> tuple[int, str]:
Expand Down
76 changes: 74 additions & 2 deletions src/quartz_api/internal/service/v1/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from quartz_api.internal.middleware.auth import AuthDependency

from .country_config import COUNTRIES, FM, RegionTypeConfig
from .helpers import api_facing_model_errors, resolve_forecast_model
from .helpers import api_facing_model_errors, parse_forecast_metadata, resolve_forecast_model
from .router import router

_auth_dep = typing.get_args(AuthDependency)[1].dependency
Expand Down Expand Up @@ -1565,6 +1565,75 @@ async def test_fetch_api_region_rejects_a_site_on_its_own() -> None:
assert excinfo.value.status_code == 404


def test_metadata_model_names_are_translated() -> None:
"""Internal forecaster names in the metadata are rewritten to their API names."""
md = parse_forecast_metadata({
"app_version": json.dumps({
"pvnet_v2": "2.3.1",
"pvnet_day_ahead": "1.0.4",
"blend": "4.1.0",
"not_a_model": "x",
}),
"forecaster": "pvnet_sat_only",
"nwp_last_updated": "2026-09-15T04:00:00Z",
})
assert md is not None
assert md["app_version"] == {
"ecmwf_mo_sat_8h": "2.3.1",
"ecmwf_mo": "1.0.4",
"blend": "4.1.0",
"not_a_model": "x",
}
# string values too, not just keys
assert md["forecaster"] == "sat_8h"
# anything that is not a model name is left exactly as the forecaster wrote it
assert md["nwp_last_updated"] == "2026-09-15T04:00:00Z"


def test_metadata_translation_keeps_colliding_names() -> None:
"""A model and its `_adjust` variant share one API name, so neither is dropped."""
md = parse_forecast_metadata(
{"app_version": json.dumps({"pvnet_v2": "1", "pvnet_v2_adjust": "2"})},
)
assert md is not None
assert md["app_version"] == {"pvnet_v2": "1", "pvnet_v2_adjust": "2"}


def test_metadata_translation_survives_odd_shapes() -> None:
"""Nested and non-string values must not trip the walk."""
md = parse_forecast_metadata({
"app_version": json.dumps({"models": ["pvnet_ecmwf", "blend"], "runs": 3}),
"ratio": 0.5,
"flag": True,
})
assert md is not None
assert md["app_version"] == {"models": ["ecmwf", "blend"], "runs": 3}
assert md["ratio"] == 0.5
assert md["flag"] is True


def test_metadata_translation_covers_every_configured_model() -> None:
"""No internal name should reach a caller. Guards a model added without a mapping."""
internal = {
name
for cfg in COUNTRIES.values()
for rt in cfg.region_types
for fm in rt.forecast_models
for name in (fm.name, fm.adjust_name)
if name
}
api_names = {
fm.api_name
for cfg in COUNTRIES.values()
for rt in cfg.region_types
for fm in rt.forecast_models
}
for name in internal:
md = parse_forecast_metadata({"forecaster": name})
assert md is not None
assert md["forecaster"] in api_names, f"{name} was not translated"


@pytest.mark.anyio
async def test_get_region_generation_time_window(client: AsyncClient) -> None:
"""Explicit start_utc + end_utc window is forwarded — endpoint returns 200."""
Expand Down Expand Up @@ -2980,7 +3049,10 @@ async def test_forecast_detail_full_parses_app_version(
"/v1/GB/solar/regions/national/forecast?detail=full",
)
metadata = body["values"][0]["metadata"]
assert metadata["app_version"] == {"blend": "1.2.22", "pvnet_v2": "3.0.1"}
# `pvnet_v2` is the platform's name for the model the API calls `ecmwf_mo_sat_8h`.
# The forecaster writes its own metadata, so it uses platform names; they are
# translated on the way out, since a caller cannot pass them back.
assert metadata["app_version"] == {"blend": "1.2.22", "ecmwf_mo_sat_8h": "3.0.1"}
assert metadata["nwp_last_updated"] == "2026-09-06T23:12:33+00:00"


Expand Down
Loading