diff --git a/api/disclaimer.py b/api/disclaimer.py new file mode 100644 index 000000000..2e94981d1 --- /dev/null +++ b/api/disclaimer.py @@ -0,0 +1,113 @@ +# =============================================================================== +# Copyright 2026 +# +# 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. +# =============================================================================== +"""Public data disclaimer page. + +Both pygeoapi mounts advertise this URL as +`metadata.identification.terms_of_service`, so it is deliberately +unauthenticated -- an OGC client following the advertised link has no +credentials to present. + +HTML is the default because the pygeoapi landing page renders +terms_of_service as a link a human clicks; JSON is offered for catalog +harvesters that want the text as data rather than markup. +""" + +import html +from typing import Annotated + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from core.disclaimer import ( + DISCLAIMER_CONTACT_EMAIL, + DISCLAIMER_PARAGRAPHS, + DISCLAIMER_TITLE, +) + +router = APIRouter(tags=["disclaimer"]) + +_STYLE = ( + "max-width:44rem;margin:3rem auto;padding:0 1.25rem;" + "font-family:system-ui,-apple-system,'Segoe UI',sans-serif;" + "line-height:1.6;color:#1a1a1a" +) + + +def _wants_json(request: Request, f: str | None) -> bool: + # An explicit ?f= wins over content negotiation, matching pygeoapi's own + # precedence so the two surfaces behave the same way. + if f is not None: + return f.lower() == "json" + accept = request.headers.get("accept", "") + return "application/json" in accept and "text/html" not in accept + + +def _render_html() -> str: + paragraphs = [] + for paragraph in DISCLAIMER_PARAGRAPHS: + escaped = html.escape(paragraph) + escaped = escaped.replace( + DISCLAIMER_CONTACT_EMAIL, + f'' + f"{DISCLAIMER_CONTACT_EMAIL}", + ) + paragraphs.append(f"

{escaped}

") + body = "\n".join(paragraphs) + title = html.escape(DISCLAIMER_TITLE) + return ( + "\n" + '\n' + " \n" + ' \n' + ' \n' + f" {title} | Ocotillo\n" + " \n" + f' \n' + f"

{title}

\n" + f"{body}\n" + " \n" + "\n" + ) + + +@router.get( + "/disclaimer", + response_class=HTMLResponse, + summary="Data disclaimer and terms of service", + responses={ + 200: { + "content": {"text/html": {}, "application/json": {}}, + "description": "The disclaimer as HTML (default) or JSON (?f=json).", + } + }, +) +def get_disclaimer( + request: Request, + f: Annotated[ + str | None, + Query(description="Response format. Use 'json' for the text as data."), + ] = None, +): + if _wants_json(request, f): + return JSONResponse( + { + "title": DISCLAIMER_TITLE, + "paragraphs": list(DISCLAIMER_PARAGRAPHS), + "contact": DISCLAIMER_CONTACT_EMAIL, + } + ) + return HTMLResponse(_render_html()) diff --git a/core/disclaimer.py b/core/disclaimer.py new file mode 100644 index 000000000..3227074e6 --- /dev/null +++ b/core/disclaimer.py @@ -0,0 +1,47 @@ +# =============================================================================== +# Copyright 2026 +# +# 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. +# =============================================================================== +"""Canonical text of the Ocotillo data disclaimer. + +The disclaimer is served at GET /disclaimer (api/disclaimer.py) and is the +target of `metadata.identification.terms_of_service` in both pygeoapi configs. +It lives here as plain constants rather than a template or a static file so +that the HTML and JSON renderings cannot drift apart, and so it ships with the +`core` package without any package-data wiring. +""" + +DISCLAIMER_TITLE = "Disclaimer" + +DISCLAIMER_CONTACT_EMAIL = "ocotillo-nmbg@nmt.edu" + +DISCLAIMER_PARAGRAPHS: tuple[str, ...] = ( + "These geospatial data are shared to help the public understand New " + "Mexico's geologic and water resources. All datasets have limitations, " + "particularly when combining data collected at different times, scales, " + "or for different purposes. Users should review the metadata for each " + "dataset and verify conditions on-site before making legal, regulatory, " + "or other high-consequence decisions. All geospatial datasets are " + "inherently scale-dependent.", + "The New Mexico Bureau of Geology and Mineral Resources (NMBGMR) provides " + "these data 'as-is' without warranties. NMBGMR does not guarantee the " + "accuracy, completeness, and timeliness of these data for any particular " + "purpose. Conditions may have changed since the data were collected. " + "Neither NMBGMR nor any partner agency providing data assumes liability " + "for any errors, omissions, or consequences arising from the use or " + "misuse of these data.", + "References to specific products or companies do not imply endorsement. " + "Proper citation of these data is appreciated. Questions or feedback: " + f"{DISCLAIMER_CONTACT_EMAIL}", +) diff --git a/core/initializers.py b/core/initializers.py index 845d831dc..14a246cb4 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -215,10 +215,12 @@ def register_api_routes(app): from api.geospatial import router as geospatial_router from api.ngwmn import router as ngwmn_router from api.feedback import router as feedback_router + from api.disclaimer import router as disclaimer_router app.include_router(asset_router) app.include_router(author_router) app.include_router(contact_router) + app.include_router(disclaimer_router) app.include_router(geospatial_router) app.include_router(group_router) app.include_router(lexicon_router) diff --git a/core/pygeoapi-config-internal.yml b/core/pygeoapi-config-internal.yml index 7bfbb1590..f2ddb5001 100644 --- a/core/pygeoapi-config-internal.yml +++ b/core/pygeoapi-config-internal.yml @@ -23,18 +23,28 @@ metadata: Authenticated internal OGC API - Features backed by PostGIS and pygeoapi. Unlike the public /ogcapi mount, these collections are not filtered by release_status and include private and draft records. + Provided without warranty - see the terms of service for data + limitations. keywords: [features, ogcapi, postgis, pygeoapi, internal] - terms_of_service: https://example.com/terms - url: https://example.com + terms_of_service: {terms_of_service_url} + url: https://ocotillo.newmexicowaterdata.org license: name: CC-BY 4.0 url: https://creativecommons.org/licenses/by/4.0/ provider: name: NMBGMR url: https://geoinfo.nmt.edu + # pygeoapi builds OpenAPI info.contact from `provider`, not `contact` + # (pygeoapi/openapi.py gen_contact), so info.contact.email is empty + # without this line. + email: ocotillo-nmbg@nmt.edu contact: - name: API Support - email: support@example.com + name: Ocotillo Support, NMBGMR + email: ocotillo-nmbg@nmt.edu + # No `role:` here. pygeoapi 0.23.5 writes contact.role into + # x-ogc-serviceContact.hoursOfService (pygeoapi/openapi.py, gen_contact -- + # the line is a copy-paste of the `hours` branch above it), so setting it + # publishes "pointOfContact" as the service's hours of operation. resources: locations: diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index ccae84eab..fb6fc6c2c 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -19,19 +19,34 @@ logging: metadata: identification: title: Ocotillo OGC API - description: OGC API - Features backed by PostGIS and pygeoapi - keywords: [features, ogcapi, postgis, pygeoapi] - terms_of_service: https://example.com/terms - url: https://example.com + # The disclaimer pointer is repeated here because the JSON landing page + # carries only title/description/links -- terms_of_service below reaches + # the HTML landing page and the OpenAPI document, but not JSON clients. + description: >- + OGC API - Features service publishing New Mexico Bureau of Geology and + Mineral Resources groundwater, geochemistry, and monitoring-location + data. Provided without warranty - see the terms of service for data + limitations. + keywords: [features, ogcapi, postgis, pygeoapi, groundwater, new mexico] + terms_of_service: {terms_of_service_url} + url: https://ocotillo.newmexicowaterdata.org license: name: CC-BY 4.0 url: https://creativecommons.org/licenses/by/4.0/ provider: name: NMBGMR url: https://geoinfo.nmt.edu + # pygeoapi builds OpenAPI info.contact from `provider`, not `contact` + # (pygeoapi/openapi.py gen_contact), so info.contact.email is empty + # without this line. + email: ocotillo-nmbg@nmt.edu contact: - name: API Support - email: support@example.com + name: Ocotillo Support, NMBGMR + email: ocotillo-nmbg@nmt.edu + # No `role:` here. pygeoapi 0.23.5 writes contact.role into + # x-ogc-serviceContact.hoursOfService (pygeoapi/openapi.py, gen_contact -- + # the line is a copy-paste of the `hours` branch above it), so setting it + # publishes "pointOfContact" as the service's hours of operation. resources: locations: diff --git a/core/pygeoapi.py b/core/pygeoapi.py index e5bb3c34a..7a3a35126 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -5,6 +5,7 @@ import textwrap from importlib.util import find_spec from pathlib import Path +from urllib.parse import urlparse import yaml from fastapi import FastAPI @@ -198,6 +199,28 @@ def _internal_server_url() -> str: return f"http://localhost:8000{_internal_mount_path()}" +def _app_base_url() -> str: + # Derived from PYGEOAPI_SERVER_URL rather than a dedicated env var: that + # variable is already set in app.template.yaml and all three CD workflows, + # and a second base-URL variable would be a fourth place to get a deploy + # wrong. PYGEOAPI_SERVER_URL points at the mount (".../ogcapi"), so strip + # the mount path back off to recover the application root. + server_url = _server_url() + mount_path = _mount_path() + if server_url.endswith(mount_path): + return server_url[: -len(mount_path)].rstrip("/") + # Deployment where the advertised OGC URL is not simply + # (a rewriting proxy, say). Scheme + netloc is the best root available. + parsed = urlparse(server_url) + if parsed.scheme and parsed.netloc: + return f"{parsed.scheme}://{parsed.netloc}" + return server_url.rstrip("/") + + +def _terms_of_service_url() -> str: + return f"{_app_base_url()}/disclaimer" + + def _pygeoapi_dir( runtime_dir_env: str = "PYGEOAPI_RUNTIME_DIR", default: str = "/tmp/pygeoapi" ) -> Path: @@ -383,6 +406,7 @@ def _write_config( ) config = template.format( server_url=server_url, + terms_of_service_url=_terms_of_service_url(), postgres_host=host, postgres_port=port, postgres_db=dbname, diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature index c8c325dad..6e3c1e71b 100644 --- a/tests/features/ogc-cleanup-sprint1.feature +++ b/tests/features/ogc-cleanup-sprint1.feature @@ -99,21 +99,26 @@ Feature: OGC Feature Layer Cleanup — Sprint 1 # A2 — Replace OGC server metadata placeholders in pygeoapi-config.yml # --------------------------------------------------------------------------- - @backend @ogc-infrastructure @sprint-1 @high-priority @A2 + @backend @ogc-infrastructure @sprint-1 @high-priority @A2 @production Scenario: Service metadata contains no placeholder or example.com values Given the service configuration has been updated with accurate metadata - When a client requests the /ogcapi landing page - Then the response body contains no "example.com" strings + When a client requests the /ogcapi landing page as JSON, as HTML, and as OpenAPI + Then no response body contains an "example.com" string - @backend @ogc-infrastructure @sprint-1 @high-priority @A2 @wip - Scenario: Landing page reflects correct contact and provider information - When a client requests the /ogcapi landing page + @backend @ogc-infrastructure @sprint-1 @high-priority @A2 @production + Scenario: Service metadata reflects correct contact and provider information + When a client requests the /ogcapi OpenAPI document Then the service metadata fields match the following values: - | field | expected-value | - | terms_of_service | TODO: confirm with technical lead | - | provider_url | https://geoinfo.nmt.edu | - | contact_name | TODO: confirm with technical lead | - | contact_email | ocotillo-nmbg@nmt.edu | + | field | expected-value | + | provider_url | https://geoinfo.nmt.edu | + | contact_name | Ocotillo Support, NMBGMR | + | contact_email | ocotillo-nmbg@nmt.edu | + And the terms of service URL resolves to the service disclaimer page + # The three fields above are asserted against the OpenAPI document, not + # the JSON landing page: in pygeoapi 0.23.5 the JSON landing page carries + # only title, description, and links. terms_of_service is checked by + # resolving it, because an advertised URL that 404s is no better than a + # placeholder. # --------------------------------------------------------------------------- # A3 — Fix broken README example URLs diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py index 2b27e539f..6d701dedd 100644 --- a/tests/features/steps/ogc-cleanup-sprint1.py +++ b/tests/features/steps/ogc-cleanup-sprint1.py @@ -13,18 +13,20 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -"""Step definitions for A1 (public release_status filter on ogc_* views) and -A11 (authenticated internal OGC mount at /ogcapi-internal). +"""Step definitions for A1 (public release_status filter on ogc_* views), +A2 (OGC server metadata placeholders) and A11 (authenticated internal OGC +mount at /ogcapi-internal). -Only the @A1- and @A11-tagged scenarios in ogc-cleanup-sprint1.feature are -implemented here. The other ~9 tickets sharing that feature file have no -steps yet and stay undefined/dormant, per this ticket's plan. +Only the @A1-, @A2- and @A11-tagged scenarios in ogc-cleanup-sprint1.feature +are implemented here. The other ~8 tickets sharing that feature file have no +steps yet and stay undefined/dormant, per those tickets' plans. """ import importlib import os from datetime import date from unittest.mock import patch +from urllib.parse import urlparse from alembic import command from behave import given, when, then @@ -855,4 +857,98 @@ def step_then_no_collection_id_prefixed(context, prefix): assert not offending, f"found collections with id prefixed {prefix!r}: {offending}" +# --------------------------------------------------------------------------- +# A2 -- Replace OGC server metadata placeholders in pygeoapi-config.yml +# --------------------------------------------------------------------------- + + +@given("the service configuration has been updated with accurate metadata") +def step_given_service_metadata_updated(context): + # No-op marker: core/pygeoapi-config.yml is the artifact under test, so + # there is no runtime state to arrange. Mirrors how the A1/A11 givens + # treat already-applied state. + pass + + +@when("a client requests the /ogcapi landing page as JSON, as HTML, and as OpenAPI") +def step_when_request_landing_page_all_formats(context): + context.metadata_responses = { + "landing page (JSON)": context.client.get("/ogcapi", params={"f": "json"}), + "landing page (HTML)": context.client.get("/ogcapi", params={"f": "html"}), + "OpenAPI document": context.client.get("/ogcapi/openapi"), + } + for label, response in context.metadata_responses.items(): + assert ( + response.status_code == 200 + ), f"{label} returned {response.status_code}, expected 200" + + +@then('no response body contains an "{needle}" string') +def step_then_no_response_contains(context, needle): + offending = [ + label + for label, response in context.metadata_responses.items() + if needle in response.text + ] + assert not offending, f"{needle!r} still present in: {', '.join(offending)}" + + +@when("a client requests the /ogcapi OpenAPI document") +def step_when_request_openapi_document(context): + context.response = context.client.get("/ogcapi/openapi") + assert ( + context.response.status_code == 200 + ), f"/ogcapi/openapi returned {context.response.status_code}, expected 200" + + +def _openapi_metadata_field(info, field): + # pygeoapi maps metadata.provider onto OpenAPI info.contact and + # metadata.contact onto the x-ogc-serviceContact extension + # (pygeoapi/openapi.py gen_contact) -- neither is on the JSON landing page. + contact = info["contact"] + service_contact = contact["x-ogc-serviceContact"] + if field == "provider_url": + return contact["url"] + if field == "contact_name": + return service_contact["name"] + if field == "contact_email": + return service_contact["emails"][0]["value"] + raise KeyError(f"unmapped metadata field {field!r}") + + +@then("the service metadata fields match the following values:") +def step_then_service_metadata_fields_match(context): + info = context.response.json()["info"] + mismatches = [] + for row in context.table: + field = row["field"] + expected = row["expected-value"] + actual = _openapi_metadata_field(info, field) + if actual != expected: + mismatches.append(f"{field}: expected {expected!r}, got {actual!r}") + assert not mismatches, "; ".join(mismatches) + + +@then("the terms of service URL resolves to the service disclaimer page") +def step_then_terms_of_service_resolves(context): + terms_url = context.response.json()["info"]["termsOfService"] + parsed = urlparse(terms_url) + assert parsed.scheme in ( + "http", + "https", + ), f"termsOfService {terms_url!r} is not an absolute http(s) URL" + assert ( + parsed.path == "/disclaimer" + ), f"termsOfService {terms_url!r} does not point at /disclaimer" + + response = context.client.get(parsed.path) + assert response.status_code == 200, ( + f"advertised termsOfService {terms_url!r} returned " + f"{response.status_code} -- a 404 is no better than a placeholder" + ) + assert ( + "New Mexico Bureau of Geology and Mineral Resources" in response.text + ), f"{terms_url!r} resolved but does not look like the disclaimer page" + + # ============= EOF ============================================= diff --git a/tests/test_disclaimer.py b/tests/test_disclaimer.py new file mode 100644 index 000000000..be694d4e6 --- /dev/null +++ b/tests/test_disclaimer.py @@ -0,0 +1,68 @@ +# =============================================================================== +# Copyright 2026 +# +# 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. +# =============================================================================== +from core.disclaimer import ( + DISCLAIMER_CONTACT_EMAIL, + DISCLAIMER_PARAGRAPHS, + DISCLAIMER_TITLE, +) +from tests import client + + +def test_disclaimer_html(): + response = client.get("/disclaimer") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + + body = response.text + assert f"

{DISCLAIMER_TITLE}

" in body + assert f'href="mailto:{DISCLAIMER_CONTACT_EMAIL}"' in body + assert "New Mexico Bureau of Geology and Mineral Resources" in body + assert body.count("

") == len(DISCLAIMER_PARAGRAPHS) + + +def test_disclaimer_json(): + response = client.get("/disclaimer", params={"f": "json"}) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + + payload = response.json() + assert payload["title"] == DISCLAIMER_TITLE + assert payload["contact"] == DISCLAIMER_CONTACT_EMAIL + assert payload["paragraphs"] == list(DISCLAIMER_PARAGRAPHS) + + +def test_disclaimer_json_via_accept_header(): + response = client.get("/disclaimer", headers={"Accept": "application/json"}) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + + +def test_disclaimer_html_wins_when_browser_accepts_both(): + # Browsers send Accept: text/html,...,*/*, which must not be read as a + # request for the JSON representation. + response = client.get( + "/disclaimer", + headers={"Accept": "text/html,application/xhtml+xml,application/json;q=0.9"}, + ) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + + +def test_disclaimer_requires_no_authentication(): + # The pygeoapi configs advertise this URL as terms_of_service, so an OGC + # client following the link has no credentials to present. + response = client.get("/disclaimer") + assert response.status_code == 200 diff --git a/tests/test_ogc.py b/tests/test_ogc.py index f711b9caa..5385d243d 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -15,6 +15,7 @@ # =============================================================================== from datetime import date, datetime from importlib.util import find_spec +from urllib.parse import urlparse import pytest from fastapi.testclient import TestClient @@ -95,6 +96,61 @@ def test_ogc_openapi_has_paths(ogc_client): assert "/collections" in payload["paths"] +# A2: every surface that echoes metadata from core/pygeoapi-config.yml. +# The JSON landing page carries only title/description/links, so the +# provider/contact/terms assertions below have to go through the OpenAPI +# document -- see pygeoapi.api.landing_page vs pygeoapi.openapi.get_oas_30. +@pytest.mark.parametrize( + "path,params", + [ + ("/ogcapi", {"f": "json"}), + ("/ogcapi", {"f": "html"}), + ("/ogcapi/openapi", {}), + ("/ogcapi/collections", {"f": "json"}), + ], +) +def test_ogc_metadata_has_no_placeholders(ogc_client, path, params): + response = ogc_client.get(path, params=params) + assert response.status_code == 200 + assert "example.com" not in response.text + + +def test_ogc_openapi_contact_metadata(ogc_client): + response = ogc_client.get("/ogcapi/openapi?f=json") + assert response.status_code == 200 + info = response.json()["info"] + + # info.contact is built from metadata.provider, and metadata.contact is + # nested under the x-ogc-serviceContact extension. + assert info["contact"]["name"] == "NMBGMR" + assert info["contact"]["url"] == "https://geoinfo.nmt.edu" + assert info["contact"]["email"] == "ocotillo-nmbg@nmt.edu" + + service_contact = info["contact"]["x-ogc-serviceContact"] + assert service_contact["name"] == "Ocotillo Support, NMBGMR" + assert service_contact["emails"][0]["value"] == "ocotillo-nmbg@nmt.edu" + + +def test_ogc_terms_of_service_resolves(ogc_client): + response = ogc_client.get("/ogcapi/openapi?f=json") + terms_url = response.json()["info"]["termsOfService"] + parsed = urlparse(terms_url) + assert parsed.scheme in ("http", "https") + assert parsed.path == "/disclaimer" + + # An advertised terms_of_service that 404s is no better than a placeholder. + disclaimer = ogc_client.get(parsed.path) + assert disclaimer.status_code == 200 + assert "New Mexico Bureau of Geology and Mineral Resources" in disclaimer.text + + +def test_ogc_landing_page_advertises_service_url(ogc_client): + response = ogc_client.get("/ogcapi", params={"f": "json"}) + about = [link for link in response.json()["links"] if link["rel"] == "about"] + assert about, "landing page has no rel=about link" + assert about[0]["href"] == "https://ocotillo.newmexicowaterdata.org" + + def test_latest_tds_observation_date_falls_back_to_collection_date(water_well_thing): with session_ctx() as session: csi = NMA_Chemistry_SampleInfo(