diff --git a/src/mcgibs/client.py b/src/mcgibs/client.py index 101aff1..a91d3ff 100644 --- a/src/mcgibs/client.py +++ b/src/mcgibs/client.py @@ -38,6 +38,9 @@ _INIT_RETRY_DELAYS = [2.0, 4.0, 8.0] # Maximum image dimensions to prevent OOM from LLM requests MAX_IMAGE_DIMENSION = 4096 +# Content types accepted for legend images (SVG is not API-compatible) +_RASTER_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"} + class _LRUCache(OrderedDict): """Simple LRU cache backed by OrderedDict.""" @@ -421,13 +424,23 @@ class GIBSClient: layer_id: str, orientation: str = "horizontal", ) -> bytes | None: - """Fetch the pre-rendered legend image for a layer.""" + """Fetch the pre-rendered legend image for a layer. + + GIBS capabilities list SVG legend URLs, but PNG versions exist + at the same path. We prefer PNG for broad client compatibility + (the Anthropic API and most image viewers don't render SVG). + """ layer = self.layer_index.get(layer_id) if layer and layer.legend_url: + url = layer.legend_url + if url.endswith(".svg"): + url = url[:-4] + ".png" try: - resp = await self.http.get(layer.legend_url) + resp = await self.http.get(url) resp.raise_for_status() - return resp.content + content_type = resp.headers.get("content-type", "").split(";")[0].strip() + if content_type in _RASTER_IMAGE_TYPES: + return resp.content except httpx.HTTPError: pass @@ -446,8 +459,8 @@ class GIBSClient: try: resp = await self.http.get(url, params=params) resp.raise_for_status() - content_type = resp.headers.get("content-type", "") - if content_type.startswith("image/"): + content_type = resp.headers.get("content-type", "").split(";")[0].strip() + if content_type in _RASTER_IMAGE_TYPES: return resp.content except httpx.HTTPError as exc: log.debug("Legend not available for %s: %s", layer_id, exc) diff --git a/tests/test_client.py b/tests/test_client.py index 8e3c692..26bd44c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -407,6 +407,74 @@ async def test_client_caches_successful_geocode(capabilities_xml): await client.close() +# --------------------------------------------------------------------------- +# Legend SVG → PNG rewriting (live API compatibility fix) +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_legend_rewrites_svg_to_png(capabilities_xml): + """Legend URLs ending in .svg should be rewritten to .png.""" + # Modify the capabilities XML so AMSR2's legend URL ends in .svg + patched_xml = capabilities_xml.replace( + "AMSR2_Sea_Ice_Concentration_12km.png", + "AMSR2_Sea_Ice_Concentration_12km.svg", + ) + respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( + return_value=httpx.Response(200, text=patched_xml) + ) + + buf = BytesIO() + Image.new("RGB", (200, 20), "white").save(buf, format="PNG") + png_route = respx.get(url__regex=r".*legends/.*\.png").mock( + return_value=httpx.Response( + 200, content=buf.getvalue(), headers={"content-type": "image/png"} + ) + ) + + client = GIBSClient() + await client.initialize() + + result = await client.get_legend_image("AMSR2_Sea_Ice_Concentration_12km_Monthly") + assert result is not None + # Verify the .png URL was requested, not .svg + assert png_route.call_count == 1 + assert ".png" in str(png_route.calls.last.request.url) + + await client.close() + + +@respx.mock +async def test_legend_rejects_svg_content(capabilities_xml): + """If the legend URL returns SVG content, it should be rejected.""" + respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( + return_value=httpx.Response(200, text=capabilities_xml) + ) + respx.get(url__regex=r".*legends/.*\.png").mock( + return_value=httpx.Response( + 200, + content=b'not supported", + headers={"content-type": "application/xml"}, + ) + ) + + client = GIBSClient() + await client.initialize() + + result = await client.get_legend_image("AMSR2_Sea_Ice_Concentration_12km_Monthly") + assert result is None + + await client.close() + + # --------------------------------------------------------------------------- # Client.http property guard # ---------------------------------------------------------------------------