From 04fafcea29ff5278ac224eff4f9dc7181e5000e0 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 23 Aug 2026 03:02:26 -0700 Subject: [PATCH 1/4] feat(gis): generate shareable QGIS and ArcGIS Pro artifacts Serves downloadable connection and layer files from /gis so a desktop GIS user reaches our OGC API - Features collections without configuring a connection by hand. Two levels per client: a connections file that registers the whole service in one import, and six curated layer files carrying symbology, field aliases, value maps and scale visibility. Generated rather than committed because every artifact embeds an absolute service URL, and there are three environments times two mounts. Static files would mean six copies of each, all going stale as collections are added -- production already advertises 30 against the 13 defined in core/pygeoapi.py. The base URL comes from _server_url(), the same value pygeoapi stamps into its own self/next links, so a client that imports a connection and then pages through items never crosses hosts. Aliases and value maps derive from core/ogc-field-descriptions.yml, the file that already feeds /schema and /queryables, so a renamed field cannot drift between the API and the shipped layer files. Value maps are emitted only where the label differs from the stored value: the lexicon columns store terms that already read as prose, and mapping them to themselves would add kilobytes of noise per layer. trend_category is the real case -- "increasing" means the water table is falling. _defaults in that file is a shared pool, not a set of universal columns; it carries well and geothermal fields side by side and describe_fields only applies the ones a view reflects. collection_fields() does the same intersection here, because unfiltered a nine-column collection ships aliases for 42 fields. QGIS drops what it cannot match; ArcGIS Pro would not. No artifact embeds a credential. Both formats allow it -- QGIS connections have username/password attributes, CIMInternetServerConnection has a user field -- but internal access uses per-user API keys so they can be revoked per user, and a shared file carrying one person's key defeats that. The two EDR collections are excluded: they publish no /items endpoint and neither client has an EDR reader, so such a layer file would not open. The curated water-level layers use the feature collections carrying the same measurements summarised per site. The ArcGIS .ogc connection file is NOT generated. Esri documents where Pro writes it but not what is in it, and it is absent from the CIM spec, so /gis gives the two-step click path instead of shipping a guess that fails in the one client we cannot test against. Verification: the .qlr format was established by loading every curated layer into a real QGIS 4.0.1 against live production -- all six load valid on the OAPIF provider, serve live features and apply renderer, aliases, value map and scale. Two findings are pinned by tests: a flattened value map segfaults QGIS rather than erroring, and a curated layer must name a collection this branch serves (the first draft of the water-level layer named one only production has). The .lyrx files follow Esri's published CIM spec but have not been opened in Pro; none is available here. uv run pytest --ignore=tests/transfers -> 1140 passed, 84 skipped, 6 xpassed Co-Authored-By: Claude Opus 5 --- api/gis_artifacts.py | 179 +++++++++ core/gis-curated-layers.yml | 116 ++++++ core/initializers.py | 2 + docs/ogc-desktop-gis-artifacts.md | 176 +++++++++ services/gis_artifacts.py | 625 ++++++++++++++++++++++++++++++ tests/test_authorization.py | 6 + tests/test_gis_artifacts.py | 321 +++++++++++++++ 7 files changed, 1425 insertions(+) create mode 100644 api/gis_artifacts.py create mode 100644 core/gis-curated-layers.yml create mode 100644 docs/ogc-desktop-gis-artifacts.md create mode 100644 services/gis_artifacts.py create mode 100644 tests/test_gis_artifacts.py diff --git a/api/gis_artifacts.py b/api/gis_artifacts.py new file mode 100644 index 000000000..915fd2d87 --- /dev/null +++ b/api/gis_artifacts.py @@ -0,0 +1,179 @@ +# =============================================================================== +# Copyright 2026 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +"""Downloadable QGIS and ArcGIS Pro artifacts for the OGC API mounts. + +The public routes are deliberately anonymous: they describe the public +`/ogcapi` mount, which is itself anonymous, and a desktop GIS user fetching a +connection file has no credential to present. Nothing they return is +sensitive -- the URLs are already advertised in the pygeoapi landing page, and +no credential is ever embedded (see services/gis_artifacts). + +The internal connection file is gated, not because the file is secret, but +because the internal mount's existence is not something to advertise to +anonymous callers. Holding it still gets you nothing without an `OGCInternal` +API key. + +Read docs/ogc-desktop-gis-artifacts.md before changing what is emitted. +""" + +from fastapi import APIRouter, HTTPException +from fastapi.responses import HTMLResponse, PlainTextResponse, Response + +from core.app import in_public_schema +from core.dependencies import session_dependency, viewer_dependency +from core.pygeoapi import _internal_server_url, _server_url +from services.gis_artifacts import ( + Connection, + arcgis_layer_file, + collection_fields, + find_curated_layer, + load_curated_layers, + qgis_connections_xml, + qgis_layer_definition, +) + +router = APIRouter(prefix="/gis", tags=["desktop gis"]) + +PUBLIC_CONNECTION_NAME = "NMBGMR Ocotillo" +INTERNAL_CONNECTION_NAME = "NMBGMR Ocotillo (internal)" + + +def _attachment(body: str, media_type: str, filename: str) -> Response: + return Response( + content=body, + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +def _public_base() -> str: + # _server_url() is what pygeoapi stamps into its own `self`/`next` links. + # Deriving the artifact's URL from the same place means a client that + # imports the connection and then pages through `items` never crosses + # hosts -- the failure mode that PYGEOAPI_INTERNAL_SERVER_URL was added to + # fix (see the comment in core/pygeoapi._internal_server_url). + return _server_url() + + +@router.get("/qgis/connections.xml", response_class=PlainTextResponse) +@in_public_schema +def qgis_connections() -> Response: + """QGIS connections file registering the public OGC API - Features mount. + + Import through **Browser panel > right-click "WFS / OGC API - Features" > + Load Connections**. + """ + body = qgis_connections_xml([Connection(PUBLIC_CONNECTION_NAME, _public_base())]) + return _attachment(body, "text/xml", "ocotillo-ogcapi-connections.xml") + + +@router.get("/qgis/connections-internal.xml", response_class=PlainTextResponse) +def qgis_connections_internal(user: viewer_dependency) -> Response: + """QGIS connections file covering the public and internal mounts. + + Carries no credential. The internal entry only resolves for a client that + attaches its own `OGCInternal` API key -- see + docs/internal-ogc-desktop-gis.md for how one is issued and attached. + """ + body = qgis_connections_xml( + [ + Connection(PUBLIC_CONNECTION_NAME, _public_base()), + Connection(INTERNAL_CONNECTION_NAME, _internal_server_url()), + ] + ) + return _attachment(body, "text/xml", "ocotillo-ogcapi-connections-internal.xml") + + +@router.get("/qgis/layers/{layer_id}.qlr", response_class=PlainTextResponse) +@in_public_schema +def qgis_layer(layer_id: str, session: session_dependency) -> Response: + """A styled QGIS layer definition for one curated layer.""" + layer = find_curated_layer(layer_id) + if layer is None: + raise HTTPException(status_code=404, detail=f"No curated layer {layer_id!r}.") + fields = collection_fields(session, layer.collection) + body = qgis_layer_definition(layer, _public_base(), fields) + return _attachment(body, "text/xml", f"{layer_id}.qlr") + + +@router.get("/arcgis/layers/{layer_id}.lyrx", response_class=PlainTextResponse) +@in_public_schema +def arcgis_layer(layer_id: str, session: session_dependency) -> Response: + """A styled ArcGIS Pro layer file for one curated layer.""" + layer = find_curated_layer(layer_id) + if layer is None: + raise HTTPException(status_code=404, detail=f"No curated layer {layer_id!r}.") + fields = collection_fields(session, layer.collection) + body = arcgis_layer_file(layer, _public_base(), fields) + return _attachment(body, "application/json", f"{layer_id}.lyrx") + + +_PAGE_STYLE = ( + "max-width:52rem;margin:3rem auto;padding:0 1.25rem;" + "font-family:system-ui,-apple-system,'Segoe UI',sans-serif;" + "line-height:1.6;color:#1a1a1a" +) + + +@router.get("", response_class=HTMLResponse) +@in_public_schema +def gis_index() -> HTMLResponse: + """Landing page listing every downloadable artifact.""" + base = _public_base() + rows = "".join( + f"{layer.title}
" + f"{layer.abstract}" + f'.qlr' + f'.lyrx' + for layer in load_curated_layers() + ) + return HTMLResponse( + f""" +Desktop GIS downloads + +

Using our OGC layers in QGIS and ArcGIS Pro

+

Service URL: {base}

+ +

Everything at once

+

QGIS connections file — +in QGIS, open the Browser panel, right-click +WFS / OGC API - Features, choose Load Connections, and pick +this file. Every collection then appears in the Browser panel.

+

ArcGIS Pro — Pro writes its own .ogc +connection file and we cannot generate one for you. Add the connection once: +Insert > Connections > Server > New OGC API Server, and paste +the service URL above. Pro saves a .ogc file into your project +folder that you can then share with colleagues.

+ +

One layer at a time

+

Styled, with field aliases already applied. Drag the file into QGIS, or add +the .lyrx to a map in Pro.

+ + +{rows} +
LayerQGISArcGIS Pro
+ +

Time series

+

Water levels and water chemistry are also published as +OGC API - EDR time series at {base}/collections/waterlevels and +{base}/collections/water_chemistry. Neither QGIS nor ArcGIS Pro +can read EDR, so the layers above carry the same measurements summarised per +site instead.

+""" + ) + + +# ============= EOF ============================================= diff --git a/core/gis-curated-layers.yml b/core/gis-curated-layers.yml new file mode 100644 index 000000000..372df17bb --- /dev/null +++ b/core/gis-curated-layers.yml @@ -0,0 +1,116 @@ +# Curated desktop-GIS layers. +# +# Each entry becomes one QGIS .qlr and one ArcGIS Pro .lyrx. These are the +# "I just want water levels" artifacts -- a small, opinionated set, not a +# mirror of the collection list. The connection files cover "give me +# everything"; anything a user can reach by browsing the connection does not +# need an entry here. +# +# `collection` must name a collection served by the OGC API - Features mount. +# The two EDR collections (waterlevels, water_chemistry) cannot appear here: +# neither QGIS nor ArcGIS Pro has an OGC API - EDR client, so a layer file +# pointing at one would not open. The feature collections below carry the same +# measurements summarised per site, which is what a GIS user wants on a map. +# +# Field aliases and value maps are NOT listed here. They are derived from +# core/ogc-field-descriptions.yml, the same file that feeds /schema and +# /queryables, so a renamed field cannot drift between the API and the shipped +# layer files. +# +# Colours are chosen to stay distinguishable for the common forms of colour +# blindness: the sequential ramps run light-to-dark so they survive being read +# by lightness alone, and the trend categories pair hue with a size difference. +# +# See docs/ogc-desktop-gis-artifacts.md. + +layers: + - id: water-wells + collection: water_wells + title: Water Wells + abstract: >- + Every groundwater well in the monitoring-point register, at its most + recent recorded location. + geometry: Point + renderer: + type: single + color: "31,119,180,255" + size: 2.2 + outline_color: "255,255,255,200" + + - id: depth-to-water + collection: water_elevation_wells + title: Depth to Water + abstract: >- + Depth to the water table at each well at its most recent measurement, in + feet below ground surface. Larger values mean a deeper water table. + geometry: Point + renderer: + type: graduated + field: depth_to_water_below_ground_surface_ft + size: 2.6 + classes: + - {lower: 0, upper: 25, label: "0 - 25 ft", color: "237,248,251,255"} + - {lower: 25, upper: 50, label: "25 - 50 ft", color: "179,205,227,255"} + - {lower: 50, upper: 100, label: "50 - 100 ft", color: "140,150,198,255"} + - {lower: 100, upper: 250, label: "100 - 250 ft", color: "136,86,167,255"} + - {lower: 250, upper: 100000, label: "over 250 ft", color: "129,15,124,255"} + + - id: water-level-trend + collection: depth_to_water_trend_wells + title: Water-Level Trend + abstract: >- + Direction of the fitted depth-to-water trend at each well. "Falling + water table" means depth below ground surface is increasing. + geometry: Point + renderer: + type: categorized + field: trend_category + size: 2.6 + categories: + - {value: "increasing", label: "Falling water table", color: "202,58,48,255", size: 3.2} + - {value: "decreasing", label: "Rising water table", color: "42,122,182,255", size: 3.2} + - {value: "stable", label: "Stable", color: "140,140,140,255", size: 2.2} + - {value: "not enough data", label: "Not enough data", color: "225,225,225,255", size: 1.8} + + - id: actively-monitored-wells + collection: actively_monitored_wells + title: Actively Monitored Wells + abstract: >- + Wells currently on a monitoring schedule, with their water-level record + summarised. + geometry: Point + renderer: + type: single + color: "44,140,80,255" + size: 2.8 + outline_color: "255,255,255,200" + + - id: springs + collection: springs + title: Springs + abstract: Natural groundwater discharge points in the register. + geometry: Point + renderer: + type: single + color: "23,150,140,255" + size: 2.6 + shape: triangle + outline_color: "255,255,255,200" + + - id: latest-tds + collection: latest_tds_wells + title: Latest Total Dissolved Solids + abstract: >- + Most recent total-dissolved-solids result at each well. 1000 mg/L is the + conventional fresh/brackish boundary. + geometry: Point + renderer: + type: graduated + field: latest_tds_value + size: 2.6 + classes: + - {lower: 0, upper: 500, label: "0 – 500 mg/L", color: "255,255,204,255"} + - {lower: 500, upper: 1000, label: "500 – 1000 mg/L", color: "161,218,180,255"} + - {lower: 1000, upper: 3000, label: "1000 – 3000 mg/L", color: "65,182,196,255"} + - {lower: 3000, upper: 10000, label: "3000 – 10000 mg/L", color: "44,127,184,255"} + - {lower: 10000, upper: 10000000, label: "over 10000 mg/L", color: "37,52,148,255"} diff --git a/core/initializers.py b/core/initializers.py index 01ef37230..9f419caa2 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -226,6 +226,7 @@ def register_api_routes(app): from api.disclaimer import router as disclaimer_router from api.geothermal import router as geothermal_router from api.chemisty import router as chemistry_router + from api.gis_artifacts import router as gis_artifacts_router app.include_router(asset_router) app.include_router(chemistry_router) @@ -233,6 +234,7 @@ def register_api_routes(app): app.include_router(contact_router) app.include_router(disclaimer_router) app.include_router(geospatial_router) + app.include_router(gis_artifacts_router) app.include_router(group_router) app.include_router(lexicon_router) app.include_router(location_router) diff --git a/docs/ogc-desktop-gis-artifacts.md b/docs/ogc-desktop-gis-artifacts.md new file mode 100644 index 000000000..3ab94a2dc --- /dev/null +++ b/docs/ogc-desktop-gis-artifacts.md @@ -0,0 +1,176 @@ +# Shareable QGIS and ArcGIS Pro artifacts + +Downloadable files that get a desktop GIS user onto our OGC API - Features +collections without them configuring a connection by hand. + +Landing page: **`/gis`**. Code: +[`services/gis_artifacts.py`](../services/gis_artifacts.py), +[`api/gis_artifacts.py`](../api/gis_artifacts.py), curated list in +[`core/gis-curated-layers.yml`](../core/gis-curated-layers.yml). + +For connecting to the authenticated `/ogcapi-internal` mount, and for how API +keys are issued, see +[`internal-ogc-desktop-gis.md`](internal-ogc-desktop-gis.md). + +## Two levels, per client + +| | QGIS | ArcGIS Pro | +|---|---|---| +| Everything | `.xml` connections file — **generated** | `.ogc` connection file — **not generated**, see below | +| One layer | `.qlr` layer definition — **generated** | `.lyrx` layer file — **generated** | + +``` +GET /gis landing page, links to everything +GET /gis/qgis/connections.xml public mount +GET /gis/qgis/connections-internal.xml public + internal (viewer role) +GET /gis/qgis/layers/{id}.qlr +GET /gis/arcgis/layers/{id}.lyrx +``` + +## Why these are generated rather than committed + +Every artifact embeds an absolute service URL, and there are three environments +(production, staging, local) times two mounts. Committing static files means +six copies of each, and each goes stale the moment a collection is added — +production already advertises **30** collections against the 13 defined in +`core/pygeoapi.py`. Generating from the running app means the URL is always the +one the caller reached us on. + +The artifacts take their base URL from `core.pygeoapi._server_url()`, the same +value pygeoapi stamps into its own `self` and `next` links. That is deliberate: +both clients follow those links to page through `items`, so an artifact +advertising a different host would work for one page and then walk off +somewhere else — the failure `PYGEOAPI_INTERNAL_SERVER_URL` was added to fix. + +## The ArcGIS `.ogc` connection file is not generated + +Pro writes a `.ogc` file into the project home folder when you add an OGC API +server connection, and that file is shareable — but **Esri does not document +its format**. It is not in the CIM spec, and the Pro help describes only where +the file lands, not what is in it. Rather than ship a guess that fails in the +one client we cannot test against, `/gis` tells the user the two-step click +path (*Insert > Connections > Server > New OGC API Server*, paste the URL) and +lets Pro write its own file, which they can then share. + +To close this properly, someone with Pro should add the connection once and +send back the resulting `.ogc`; templating it after that is a small change to +`services/gis_artifacts.py`. + +## EDR is deliberately absent from the curated layers + +`waterlevels` and `water_chemistry` are served by the EDR provider only — they +publish no `/items` endpoint. **Neither QGIS nor ArcGIS Pro has an OGC API - EDR +client**, so a layer file pointing at either would not open. The curated +"water levels" layer is `latest_depth_to_water_wells`, which carries the same +measurement summarised per site, which is what a GIS user wants on a map. +`test_no_curated_layer_points_at_an_edr_collection` enforces this. + +## The curated list is checked against *this branch*, not production + +Production advertises 30 collections; this branch defines 13 in +`core/pygeoapi.py` plus 14 in the `core/pygeoapi-config.yml` template. A +curated layer written against a live deployment can therefore name a +collection that does not exist here, and the artifact 404s the moment a user +opens it. `test_every_curated_layer_names_a_collection_this_branch_serves` +reads both sources and fails on the mismatch. It caught exactly that during +development: the first draft of the "water levels" layer pointed at +`latest_depth_to_water_wells`, which only production serves. It now uses +`water_elevation_wells`. + +## Aliases are intersected with the view's real columns + +`_defaults` in `core/ogc-field-descriptions.yml` is a **shared pool, not a set +of universal columns** -- it carries well fields and geothermal fields side by +side, and `describe_fields` only ever applies the ones a given view actually +reflects. The generator must do the same intersection: unfiltered, a +nine-column collection ships aliases for 42 fields. QGIS silently drops the +ones it cannot match, but ArcGIS Pro takes `fieldDescriptions` at its word. + +`collection_fields()` reflects `ogc_` from `information_schema` +and the routes pass the result through. It returns `None` when the view is +absent -- a branch whose migrations have not created it yet -- and the caller +then falls back to the full entry list rather than emitting a layer file with +no aliases at all. + +## Field aliases and value maps are derived, not written twice + +Aliases come from `core/ogc-field-descriptions.yml` through +`core.ogc_field_metadata.table_entries()` — the same file that feeds `/schema` +and `/queryables`. A renamed or re-titled field therefore cannot drift between +the API and the shipped layer files, and +`test_qlr_aliases_cover_every_documented_field` fails if one does. + +Value maps are emitted **only where the display label differs from the stored +value**, which in practice means the categorised renderer's own labels. The +lexicon-backed columns (`thing_type`, `release_status`) already store terms +that read as prose, so mapping them to themselves would add kilobytes of +`name == value` noise per layer and give the user a dropdown that renames +nothing. `trend_category` is the real case: `increasing` means the water table +is *falling*. + +## No artifact ever embeds a credential + +QGIS's connection format has `username` and `password` attributes, and Esri's +`CIMInternetServerConnection` has a `user` field, so embedding a key is +possible in both formats. It is not done. Internal access uses per-user API +keys precisely so they can be revoked per user; a shared file carrying one +person's key defeats that. `connections-internal.xml` ships the internal URL +credential-free and the user attaches their own key in their own client. +`test_no_artifact_embeds_a_credential` guards this. + +Note that Esri's own spec marks the CIM connection password *"not persisted in +documents"*, so `.lyrx` could not carry one even if we wanted it to. + +## Verification + +`tests/test_gis_artifacts.py` covers what can be checked without a GIS +installed: XML/JSON well-formedness, the datasource URI, tree-node and maplayer +agreement, renderer fields existing on the collection, alias coverage, the +EDR exclusion, credential absence, and QGIS/ArcGIS renderer agreement. + +**That is not the same as the file opening.** The formats were established by +loading them into a real QGIS 4.0.1 (`QgsLayerDefinition.loadLayerDefinition`) +against the live production service, which confirmed for all six curated +layers: the layer loads valid on the `OAPIF` provider, serves live features +(2453 for the trend layer), and applies the renderer, every alias, the value +map and scale visibility. + +Two findings from that exercise are worth keeping: + +- **A malformed value map segfaults QGIS 4.0.1 rather than erroring.** Each + entry must be its own `\n" + " \n" + " \n" + " " + ) + + scale_attrs = "" + if layer.min_scale: + scale_attrs = ( + f' hasScaleBasedVisibilityFlag="1" minScale="{layer.min_scale}"' + ' maxScale="0"' + ) + + field_config = "" + if widget_lines: + field_config = ( + " \n" + + "\n".join(widget_lines) + + "\n \n" + ) + + return ( + "\n" + "\n" + ' \n' + f" \n" + " \n" + " \n" + " \n" + " \n" + f' \n' + f" {escape(layer.id)}\n" + f" {escape(uri)}\n" + f" {escape(layer.title)}\n" + f" {escape(layer.abstract)}\n" + " OGC:CRS84\n" + f" {QGIS_PROVIDER}\n" + f"{_qgis_renderer(layer.renderer)}\n" + " \n" + "\n".join(alias_lines) + "\n \n" + f"{field_config}" + " \n" + " \n" + "\n" + ) + + +# -------------------------------------------------------------------------- +# ArcGIS Pro +# -------------------------------------------------------------------------- + +# CIM types per Esri's published spec (Esri/cim-spec, docs/v3): +# CIMOGCAPIServiceConnection carries serviceName + serverConnection, and +# CIMInternetServerConnection carries the URL. The spec marks the connection's +# `password` "not persisted in documents", which is the same reason the +# internal artifacts here carry no credential. +CIM_VERSION = "3.3.0" + + +def _cim_color(rgba: str) -> dict: + r, g, b, a = (int(part) for part in rgba.split(",")) + return {"type": "CIMRGBColor", "values": [r, g, b, round(a / 255 * 100, 2)]} + + +def _cim_marker(rgba: str, size: float) -> dict: + return { + "type": "CIMPointSymbol", + "symbolLayers": [ + { + "type": "CIMVectorMarker", + "enable": True, + "size": size * 2, + "frame": {"xmin": -2, "ymin": -2, "xmax": 2, "ymax": 2}, + "markerGraphics": [ + { + "type": "CIMMarkerGraphic", + "geometry": {"x": 0, "y": 0}, + "symbol": { + "type": "CIMPolygonSymbol", + "symbolLayers": [ + { + "type": "CIMSolidFill", + "enable": True, + "color": _cim_color(rgba), + } + ], + }, + } + ], + } + ], + } + + +def _cim_renderer(renderer: dict) -> dict: + kind = renderer["type"] + size = renderer.get("size", 2.4) + + if kind == "single": + return { + "type": "CIMSimpleRenderer", + "patch": "Default", + "symbol": { + "type": "CIMSymbolReference", + "symbol": _cim_marker(renderer.get("color", "31,119,180,255"), size), + }, + } + + if kind == "categorized": + groups = [ + { + "type": "CIMUniqueValueGroup", + "classes": [ + { + "type": "CIMUniqueValueClass", + "label": item["label"], + "patch": "Default", + "symbol": { + "type": "CIMSymbolReference", + "symbol": _cim_marker( + item["color"], item.get("size", size) + ), + }, + "values": [ + { + "type": "CIMUniqueValue", + "fieldValues": [str(item["value"])], + } + ], + "visible": True, + } + for item in renderer["categories"] + ], + } + ] + return { + "type": "CIMUniqueValueRenderer", + "fields": [renderer["field"]], + "groups": groups, + "useDefaultSymbol": True, + } + + return { + "type": "CIMClassBreaksRenderer", + "classBreakType": "GraduatedColor", + "classificationMethod": "Manual", + "field": renderer["field"], + "breaks": [ + { + "type": "CIMClassBreak", + "label": item["label"], + "patch": "Default", + "upperBound": item["upper"], + "symbol": { + "type": "CIMSymbolReference", + "symbol": _cim_marker(item["color"], item.get("size", size)), + }, + } + for item in renderer["classes"] + ], + } + + +def arcgis_layer_file( + layer: CuratedLayer, base_url: str, fields: set[str] | None = None +) -> str: + """An ArcGIS Pro layer file (.lyrx) for one curated layer. + + NOTE: unlike the QGIS artifacts, this has NOT been verified by opening it + in the target client -- no ArcGIS Pro is available to this project. It is + built to Esri's published CIM spec. Treat the first open in Pro as the real + test. See docs/ogc-desktop-gis-artifacts.md. + """ + aliases = field_aliases(layer.collection, fields) + connection = { + "type": "CIMOGCAPIServiceConnection", + "serviceName": layer.collection, + "serverConnection": { + "type": "CIMInternetServerConnection", + "anonymous": True, + "hideUserProperty": True, + "URL": base_url, + }, + } + + definition = { + "type": "CIMLayerDocument", + "version": CIM_VERSION, + "layers": [f"CIMPATH=/{layer.id}.xml"], + "layerDefinitions": [ + { + "type": "CIMFeatureLayer", + "name": layer.title, + "uRI": f"CIMPATH=/{layer.id}.xml", + "description": layer.abstract, + "visibility": True, + "expanded": True, + "layerType": "Operational", + "minScale": layer.min_scale or 0, + "maxScale": 0, + "featureTable": { + "type": "CIMFeatureTable", + "displayField": "name", + "editable": False, + "dataConnection": connection, + "studyAreaSpatialRel": "esriSpatialRelUndefined", + "searchOrder": "esriSearchOrderSpatial", + "fieldDescriptions": [ + { + "type": "CIMFieldDescription", + "alias": title, + "fieldName": name, + "visible": True, + "searchMode": "Exact", + } + for name, title in sorted(aliases.items()) + ], + }, + "renderer": _cim_renderer(layer.renderer), + "scaleSymbols": True, + "snappable": False, + } + ], + } + return json.dumps(definition, indent=2) + "\n" + + +# ============= EOF ============================================= diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 02ce5caee..5593d8e9e 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -54,6 +54,12 @@ ("GET", "/docs-auth/oauth2-redirect"), ("GET", "/openapi-auth.json"), ("GET", "/disclaimer"), + # Desktop-GIS artifacts describe the anonymous /ogcapi mount and embed + # no credential; a QGIS client fetching one has nothing to present. + ("GET", "/gis"), + ("GET", "/gis/qgis/connections.xml"), + ("GET", "/gis/qgis/layers/{layer_id}.qlr"), + ("GET", "/gis/arcgis/layers/{layer_id}.lyrx"), ("GET", "/ngwmn/waterlevels/{pointid}"), ("GET", "/ngwmn/wellconstruction/{pointid}"), ("GET", "/ngwmn/lithology/{pointid}"), diff --git a/tests/test_gis_artifacts.py b/tests/test_gis_artifacts.py new file mode 100644 index 000000000..e9f855c18 --- /dev/null +++ b/tests/test_gis_artifacts.py @@ -0,0 +1,321 @@ +# =============================================================================== +# Copyright 2026 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +"""Guards on the generated QGIS and ArcGIS Pro artifacts. + +These assert the invariants a broken artifact would violate silently -- the +file still parses, still downloads, and only fails when a GIS user opens it +hours later. Loading a .qlr into a real QGIS is the check that actually proves +the format, and QGIS is not a CI dependency; see +docs/ogc-desktop-gis-artifacts.md for the manual procedure and what it covered. +""" + +import json +import xml.etree.ElementTree as ET + +import pytest + +from core.ogc_field_metadata import table_entries +from tests import client +from core.pygeoapi import EDR_COLLECTIONS, THING_COLLECTIONS +from services.gis_artifacts import ( + Connection, + arcgis_layer_file, + field_value_maps, + find_curated_layer, + load_curated_layers, + qgis_connections_xml, + qgis_datasource_uri, + qgis_layer_definition, +) + +BASE = "https://example.org/ogcapi" + + +@pytest.fixture(scope="module") +def layers(): + return load_curated_layers() + + +def test_curated_config_parses(layers): + assert layers, "gis-curated-layers.yml declares no layers." + + +@pytest.mark.parametrize("layer_id", [layer.id for layer in load_curated_layers()]) +def test_qlr_is_well_formed_and_points_at_the_collection(layer_id): + layer = find_curated_layer(layer_id) + root = ET.fromstring(qgis_layer_definition(layer, BASE)) + + assert root.tag == "qlr" + maplayer = root.find("./maplayers/maplayer") + assert maplayer.findtext("provider") == "OAPIF" + assert maplayer.findtext("datasource") == qgis_datasource_uri( + BASE, layer.collection + ) + # The layer-tree entry and the maplayer must agree, or QGIS loads the tree + # node and finds no layer behind it. + tree_layer = root.find("./layer-tree-group/layer-tree-layer") + assert tree_layer.get("source") == maplayer.findtext("datasource") + assert tree_layer.get("id") == maplayer.findtext("id") + + +@pytest.mark.parametrize("layer_id", [layer.id for layer in load_curated_layers()]) +def test_qlr_renderer_field_exists_on_the_collection(layer_id): + """A renderer pointed at a field the view does not have renders nothing.""" + layer = find_curated_layer(layer_id) + field = layer.renderer.get("field") + if field is None: + pytest.skip("single-symbol renderer classifies on no field") + assert field in table_entries(layer.collection), ( + f"{layer.id} classifies on {field!r}, which has no entry for " + f"{layer.collection} in core/ogc-field-descriptions.yml." + ) + + +@pytest.mark.parametrize("layer_id", [layer.id for layer in load_curated_layers()]) +def test_qlr_aliases_cover_every_documented_field(layer_id): + layer = find_curated_layer(layer_id) + root = ET.fromstring(qgis_layer_definition(layer, BASE)) + aliased = {a.get("field") for a in root.findall(".//aliases/alias")} + assert aliased == set(table_entries(layer.collection)) + + +def test_qlr_value_map_entries_are_nested_option_maps(): + """QGIS 4.0.1 segfaults on a flattened value map rather than erroring. + + Each entry must be its own