From 973a1099eb30ac9040d0ec4621558596aa534505 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 14 Jan 2026 16:30:18 -0700 Subject: [PATCH 1/9] feat: implement OGC API - Features endpoints with collections and item retrieval Issue #333 --- README.md | 50 +++++ api/ogc/__init__.py | 1 + api/ogc/collections.py | 95 ++++++++++ api/ogc/conformance.py | 8 + api/ogc/features.py | 418 +++++++++++++++++++++++++++++++++++++++++ api/ogc/router.py | 110 +++++++++++ api/ogc/schemas.py | 67 +++++++ core/initializers.py | 2 + tests/conftest.py | 14 ++ tests/test_ogc.py | 110 +++++++++++ 10 files changed, 875 insertions(+) create mode 100644 api/ogc/__init__.py create mode 100644 api/ogc/collections.py create mode 100644 api/ogc/conformance.py create mode 100644 api/ogc/features.py create mode 100644 api/ogc/router.py create mode 100644 api/ogc/schemas.py create mode 100644 tests/test_ogc.py diff --git a/README.md b/README.md index b35d4933..2f7804d8 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,56 @@ alembic upgrade head # start development server uvicorn app.main:app --reload ``` + +--- + +## 🗺️ OGC API - Features + +The API exposes OGC API - Features endpoints under `/ogc`. + +### Landing & metadata + +```bash +curl http://localhost:8000/ogc +curl http://localhost:8000/ogc/conformance +curl http://localhost:8000/ogc/collections +curl http://localhost:8000/ogc/collections/locations +``` + +### Items (GeoJSON) + +```bash +curl "http://localhost:8000/ogc/collections/locations/items?limit=10&offset=0" +curl "http://localhost:8000/ogc/collections/wells/items?limit=5" +curl "http://localhost:8000/ogc/collections/springs/items?limit=5" +curl "http://localhost:8000/ogc/collections/locations/items/123" +``` + +### BBOX + datetime filters + +```bash +curl "http://localhost:8000/ogc/collections/locations/items?bbox=-107.9,33.8,-107.8,33.9" +curl "http://localhost:8000/ogc/collections/wells/items?datetime=2020-01-01/2024-01-01" +``` + +### Polygon filter (CQL2 text) + +Use `filter` + `filter-lang=cql2-text` with `WITHIN(...)`: + +```bash +curl "http://localhost:8000/ogc/collections/locations/items?filter=WITHIN(geometry,POLYGON((-107.9 33.8,-107.8 33.8,-107.8 33.9,-107.9 33.9,-107.9 33.8)))&filter-lang=cql2-text" +``` + +### Property filter (CQL) + +Basic property filters are supported with `properties`: + +```bash +curl "http://localhost:8000/ogc/collections/wells/items?properties=thing_type='water well' AND well_depth=100" +curl "http://localhost:8000/ogc/collections/wells/items?properties=well_purposes IN ('domestic','irrigation')" +curl "http://localhost:8000/ogc/collections/wells/items?properties=well_casing_materials='PVC'" +curl "http://localhost:8000/ogc/collections/wells/items?properties=well_screen_type='Steel'" +``` diff --git a/api/ogc/__init__.py b/api/ogc/__init__.py new file mode 100644 index 00000000..a03d84c6 --- /dev/null +++ b/api/ogc/__init__.py @@ -0,0 +1 @@ +# ============= OGC API package ============================================= diff --git a/api/ogc/collections.py b/api/ogc/collections.py new file mode 100644 index 00000000..85067d2d --- /dev/null +++ b/api/ogc/collections.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Dict + +from fastapi import Request + +from api.ogc.schemas import Collection, CollectionExtent, CollectionExtentSpatial, Link + + +BASE_CRS = "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + + +COLLECTIONS: Dict[str, dict] = { + "locations": { + "title": "Locations", + "description": "Sample locations", + "itemType": "feature", + "priority": "P0", + }, + "wells": { + "title": "Wells", + "description": "Things filtered to water wells", + "itemType": "feature", + "priority": "P0", + }, + "springs": { + "title": "Springs", + "description": "Things filtered to springs", + "itemType": "feature", + "priority": "P1", + }, +} + + +def _collection_links(request: Request, collection_id: str) -> list[Link]: + base = str(request.base_url).rstrip("/") + return [ + Link( + href=f"{base}/ogc/collections/{collection_id}", + rel="self", + type="application/json", + ), + Link( + href=f"{base}/ogc/collections/{collection_id}/items", + rel="items", + type="application/geo+json", + ), + Link( + href=f"{base}/ogc/collections", + rel="collection", + type="application/json", + ), + ] + + +def list_collections(request: Request) -> list[Collection]: + collections = [] + for cid, meta in COLLECTIONS.items(): + extent = CollectionExtent( + spatial=CollectionExtentSpatial( + bbox=[[-180.0, -90.0, 180.0, 90.0]], crs=BASE_CRS + ) + ) + collections.append( + Collection( + id=cid, + title=meta["title"], + description=meta.get("description"), + itemType=meta.get("itemType", "feature"), + crs=[BASE_CRS], + links=_collection_links(request, cid), + extent=extent, + ) + ) + return collections + + +def get_collection(request: Request, collection_id: str) -> Collection | None: + meta = COLLECTIONS.get(collection_id) + if not meta: + return None + extent = CollectionExtent( + spatial=CollectionExtentSpatial( + bbox=[[-180.0, -90.0, 180.0, 90.0]], crs=BASE_CRS + ) + ) + return Collection( + id=collection_id, + title=meta["title"], + description=meta.get("description"), + itemType=meta.get("itemType", "feature"), + crs=[BASE_CRS], + links=_collection_links(request, collection_id), + extent=extent, + ) diff --git a/api/ogc/conformance.py b/api/ogc/conformance.py new file mode 100644 index 00000000..c02872ca --- /dev/null +++ b/api/ogc/conformance.py @@ -0,0 +1,8 @@ +CONFORMANCE_CLASSES = [ + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/collections", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/features", + "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text", +] diff --git a/api/ogc/features.py b/api/ogc/features.py new file mode 100644 index 00000000..e725c102 --- /dev/null +++ b/api/ogc/features.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone +import re +from typing import Any, Dict, Tuple + +from fastapi import HTTPException, Request +from geoalchemy2.functions import ( + ST_AsGeoJSON, + ST_GeomFromText, + ST_Intersects, + ST_MakeEnvelope, + ST_Within, +) +from sqlalchemy import exists, func, select +from sqlalchemy.orm import aliased + +from core.constants import SRID_WGS84 +from db.location import Location, LocationThingAssociation +from db.thing import Thing, WellCasingMaterial, WellPurpose, WellScreen + + +def _parse_bbox(bbox: str) -> Tuple[float, float, float, float]: + try: + parts = [float(part) for part in bbox.split(",")] + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid bbox format") from exc + if len(parts) not in (4, 6): + raise HTTPException(status_code=400, detail="bbox must have 4 or 6 values") + return parts[0], parts[1], parts[2], parts[3] + + +def _parse_datetime(value: str) -> datetime: + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + parsed = datetime.fromisoformat(text) + if isinstance(parsed, datetime): + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + return datetime.combine(parsed, datetime.min.time(), tzinfo=timezone.utc) + + +def _parse_datetime_range(value: str) -> Tuple[datetime | None, datetime | None]: + if "/" in value: + start_text, end_text = value.split("/", 1) + start = _parse_datetime(start_text) if start_text else None + end = _parse_datetime(end_text) if end_text else None + return start, end + single = _parse_datetime(value) + return single, single + + +def _coerce_value(value: str) -> Any: + stripped = value.strip() + if stripped.startswith("'") and stripped.endswith("'"): + return stripped[1:-1] + if stripped.startswith('"') and stripped.endswith('"'): + return stripped[1:-1] + try: + if "." in stripped: + return float(stripped) + return int(stripped) + except ValueError: + return stripped + + +def _apply_properties_filter( + query, + properties: str, + column_map: Dict[str, Any], + relationship_map: Dict[str, Any] | None = None, +): + relationship_map = relationship_map or {} + clauses = [ + clause.strip() + for clause in re.split(r"\s+AND\s+", properties, flags=re.IGNORECASE) + if clause.strip() + ] + for clause in clauses: + in_match = re.match( + r"^\s*(\w+)\s+IN\s+\((.+)\)\s*$", clause, flags=re.IGNORECASE + ) + if in_match: + field = in_match.group(1) + values = [val.strip() for val in in_match.group(2).split(",")] + if field in relationship_map: + query = query.where( + relationship_map[field]([_coerce_value(v) for v in values]) + ) + continue + if field not in column_map: + raise HTTPException( + status_code=400, detail=f"Unsupported property: {field}" + ) + query = query.where( + column_map[field].in_([_coerce_value(v) for v in values]) + ) + continue + eq_match = re.match(r"^\s*(\w+)\s*=\s*(.+)\s*$", clause) + if eq_match: + field = eq_match.group(1) + value = eq_match.group(2) + if field in relationship_map: + query = query.where(relationship_map[field]([_coerce_value(value)])) + continue + if field not in column_map: + raise HTTPException( + status_code=400, detail=f"Unsupported property: {field}" + ) + query = query.where(column_map[field] == _coerce_value(value)) + continue + raise HTTPException( + status_code=400, detail=f"Unsupported CQL expression: {clause}" + ) + return query + + +def _apply_cql_filter(query, filter_expr: str): + match = re.match( + r"^\s*(INTERSECTS|WITHIN)\s*\(\s*(geometry|geom)\s*,\s*(POLYGON|MULTIPOLYGON)\s*(\(.+\))\s*\)\s*$", + filter_expr, + flags=re.IGNORECASE | re.DOTALL, + ) + if not match: + raise HTTPException(status_code=400, detail="Unsupported CQL filter expression") + op = match.group(1).upper() + wkt = f"{match.group(3).upper()} {match.group(4)}" + geom = ST_GeomFromText(wkt, SRID_WGS84) + if op == "WITHIN": + return query.where(ST_Within(Location.point, geom)) + return query.where(ST_Intersects(Location.point, geom)) + + +def _latest_location_subquery(): + return ( + select( + LocationThingAssociation.thing_id, + func.max(LocationThingAssociation.effective_start).label("max_start"), + ) + .where(LocationThingAssociation.effective_end == None) + .group_by(LocationThingAssociation.thing_id) + .subquery() + ) + + +def _location_query(): + return select( + Location, + ST_AsGeoJSON(Location.point).label("geojson"), + ) + + +def _thing_query(thing_type: str): + lta_alias = aliased(LocationThingAssociation) + latest_assoc = _latest_location_subquery() + return ( + select( + Thing, + ST_AsGeoJSON(Location.point).label("geojson"), + ) + .join(lta_alias, Thing.id == lta_alias.thing_id) + .join(Location, lta_alias.location_id == Location.id) + .join( + latest_assoc, + (latest_assoc.c.thing_id == lta_alias.thing_id) + & (latest_assoc.c.max_start == lta_alias.effective_start), + ) + .where(Thing.thing_type == thing_type) + ) + + +def _apply_bbox_filter(query, bbox: str): + minx, miny, maxx, maxy = _parse_bbox(bbox) + envelope = ST_MakeEnvelope(minx, miny, maxx, maxy, SRID_WGS84) + return query.where(ST_Intersects(Location.point, envelope)) + + +def _apply_datetime_filter(query, datetime_value: str, column): + start, end = _parse_datetime_range(datetime_value) + if start is not None: + query = query.where(column >= start) + if end is not None: + query = query.where(column <= end) + return query + + +def _build_feature(row, collection_id: str) -> dict[str, Any]: + model, geojson = row + geometry = {} if geojson is None else _safe_json(geojson) + if collection_id == "locations": + properties = { + "id": model.id, + "description": model.description, + "county": model.county, + "state": model.state, + "quad_name": model.quad_name, + "elevation": model.elevation, + } + else: + properties = { + "id": model.id, + "name": model.name, + "thing_type": model.thing_type, + "first_visit_date": model.first_visit_date, + "nma_pk_welldata": model.nma_pk_welldata, + "well_depth": model.well_depth, + "hole_depth": model.hole_depth, + "well_casing_diameter": model.well_casing_diameter, + "well_casing_depth": model.well_casing_depth, + "well_completion_date": model.well_completion_date, + "well_driller_name": model.well_driller_name, + "well_construction_method": model.well_construction_method, + "well_pump_type": model.well_pump_type, + "well_pump_depth": model.well_pump_depth, + "formation_completion_code": model.formation_completion_code, + "is_suitable_for_datalogger": model.is_suitable_for_datalogger, + } + if collection_id == "wells": + properties["well_purposes"] = [ + purpose.purpose for purpose in (model.well_purposes or []) + ] + properties["well_casing_materials"] = [ + casing.material for casing in (model.well_casing_materials or []) + ] + properties["well_screens"] = [ + { + "screen_depth_top": screen.screen_depth_top, + "screen_depth_bottom": screen.screen_depth_bottom, + "screen_type": screen.screen_type, + "screen_description": screen.screen_description, + } + for screen in (model.screens or []) + ] + if hasattr(model, "nma_formation_zone"): + properties["nma_formation_zone"] = model.nma_formation_zone + return { + "type": "Feature", + "id": model.id, + "geometry": geometry, + "properties": _json_ready(properties), + } + + +def _safe_json(value: str) -> dict[str, Any]: + try: + return __import__("json").loads(value) + except Exception: + return {} + + +def _json_ready(value: Any) -> Any: + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, dict): + return {key: _json_ready(val) for key, val in value.items()} + if isinstance(value, (list, tuple)): + return [_json_ready(val) for val in value] + return value + + +def get_items( + request: Request, + session, + collection_id: str, + bbox: str | None, + datetime_value: str | None, + limit: int, + offset: int, + properties: str | None, + filter_expr: str | None, + filter_lang: str | None, +) -> dict[str, Any]: + if collection_id == "locations": + query = _location_query() + column_map = { + "id": Location.id, + "description": Location.description, + "county": Location.county, + "state": Location.state, + "quad_name": Location.quad_name, + "release_status": Location.release_status, + } + datetime_column = Location.created_at + relationship_map = {} + elif collection_id == "wells": + query = _thing_query("water well") + column_map = { + "id": Thing.id, + "name": Thing.name, + "thing_type": Thing.thing_type, + "first_visit_date": Thing.first_visit_date, + "nma_pk_welldata": Thing.nma_pk_welldata, + "well_depth": Thing.well_depth, + "hole_depth": Thing.hole_depth, + "well_casing_diameter": Thing.well_casing_diameter, + "well_casing_depth": Thing.well_casing_depth, + "well_completion_date": Thing.well_completion_date, + "well_driller_name": Thing.well_driller_name, + "well_construction_method": Thing.well_construction_method, + "well_pump_type": Thing.well_pump_type, + "well_pump_depth": Thing.well_pump_depth, + "formation_completion_code": Thing.formation_completion_code, + "is_suitable_for_datalogger": Thing.is_suitable_for_datalogger, + } + if hasattr(Thing, "nma_formation_zone"): + column_map["nma_formation_zone"] = Thing.nma_formation_zone + datetime_column = Thing.created_at + relationship_map = { + "well_purposes": lambda values: exists( + select(1).where( + WellPurpose.thing_id == Thing.id, + WellPurpose.purpose.in_(values), + ) + ), + "well_casing_materials": lambda values: exists( + select(1).where( + WellCasingMaterial.thing_id == Thing.id, + WellCasingMaterial.material.in_(values), + ) + ), + "well_screen_type": lambda values: exists( + select(1).where( + WellScreen.thing_id == Thing.id, + WellScreen.screen_type.in_(values), + ) + ), + } + elif collection_id == "springs": + query = _thing_query("spring") + column_map = { + "id": Thing.id, + "name": Thing.name, + "thing_type": Thing.thing_type, + "nma_pk_welldata": Thing.nma_pk_welldata, + } + datetime_column = Thing.created_at + relationship_map = {} + else: + raise HTTPException(status_code=404, detail="Collection not found") + + if bbox: + query = _apply_bbox_filter(query, bbox) + if datetime_value: + query = _apply_datetime_filter(query, datetime_value, datetime_column) + if properties: + query = _apply_properties_filter( + query, properties, column_map, relationship_map + ) + if filter_expr: + if filter_lang and filter_lang.lower() != "cql2-text": + raise HTTPException(status_code=400, detail="Unsupported filter-lang") + query = _apply_cql_filter(query, filter_expr) + + total = session.execute( + select(func.count()).select_from(query.subquery()) + ).scalar_one() + rows = session.execute(query.limit(limit).offset(offset)).all() + features = [_build_feature(row, collection_id) for row in rows] + + base = str(request.base_url).rstrip("/") + links = [ + { + "href": f"{base}/ogc/collections/{collection_id}/items?limit={limit}&offset={offset}", + "rel": "self", + "type": "application/geo+json", + }, + { + "href": f"{base}/ogc/collections/{collection_id}", + "rel": "collection", + "type": "application/json", + }, + ] + + return { + "type": "FeatureCollection", + "features": features, + "links": links, + "numberMatched": total, + "numberReturned": len(features), + } + + +def get_item( + request: Request, + session, + collection_id: str, + fid: int, +) -> dict[str, Any]: + if collection_id == "locations": + query = _location_query().where(Location.id == fid) + elif collection_id == "wells": + query = _thing_query("water well").where(Thing.id == fid) + elif collection_id == "springs": + query = _thing_query("spring").where(Thing.id == fid) + else: + raise HTTPException(status_code=404, detail="Collection not found") + + row = session.execute(query).first() + if row is None: + raise HTTPException(status_code=404, detail="Feature not found") + + feature = _build_feature(row, collection_id) + base = str(request.base_url).rstrip("/") + feature["links"] = [ + { + "href": f"{base}/ogc/collections/{collection_id}/items/{fid}", + "rel": "self", + "type": "application/geo+json", + }, + { + "href": f"{base}/ogc/collections/{collection_id}", + "rel": "collection", + "type": "application/json", + }, + ] + return feature diff --git a/api/ogc/router.py b/api/ogc/router.py new file mode 100644 index 00000000..b17a1d65 --- /dev/null +++ b/api/ogc/router.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Query, Request +from starlette.responses import JSONResponse + +from api.ogc.collections import get_collection, list_collections +from api.ogc.conformance import CONFORMANCE_CLASSES +from api.ogc.features import get_item, get_items +from api.ogc.schemas import Conformance, LandingPage +from core.dependencies import session_dependency, viewer_dependency + +router = APIRouter(prefix="/ogc", tags=["ogc"]) + + +@router.get("") +def landing_page(request: Request) -> LandingPage: + base = str(request.base_url).rstrip("/") + return { + "title": "Ocotillo OGC API", + "description": "OGC API - Features endpoints", + "links": [ + { + "href": f"{base}/ogc", + "rel": "self", + "type": "application/json", + }, + { + "href": f"{base}/ogc/conformance", + "rel": "conformance", + "type": "application/json", + }, + { + "href": f"{base}/ogc/collections", + "rel": "data", + "type": "application/json", + }, + ], + } + + +@router.get("/conformance") +def conformance() -> Conformance: + return {"conformsTo": CONFORMANCE_CLASSES} + + +@router.get("/collections") +def collections(request: Request) -> JSONResponse: + base = str(request.base_url).rstrip("/") + payload = { + "links": [ + { + "href": f"{base}/ogc/collections", + "rel": "self", + "type": "application/json", + } + ], + "collections": [c.model_dump() for c in list_collections(request)], + } + return JSONResponse(content=payload, media_type="application/json") + + +@router.get("/collections/{collection_id}") +def collection(request: Request, collection_id: str) -> JSONResponse: + record = get_collection(request, collection_id) + if record is None: + return JSONResponse(status_code=404, content={"detail": "Collection not found"}) + return JSONResponse(content=record.model_dump(), media_type="application/json") + + +@router.get("/collections/{collection_id}/items") +def items( + request: Request, + user: viewer_dependency, + session: session_dependency, + collection_id: str, + bbox: Annotated[str | None, Query(description="minx,miny,maxx,maxy")] = None, + datetime: Annotated[str | None, Query(alias="datetime")] = None, + limit: Annotated[int, Query(ge=1, le=1000)] = 100, + offset: Annotated[int, Query(ge=0)] = 0, + properties: Annotated[str | None, Query(description="CQL filter")] = None, + filter_: Annotated[str | None, Query(alias="filter")] = None, + filter_lang: Annotated[str | None, Query(alias="filter-lang")] = None, +): + payload = get_items( + request, + session, + collection_id, + bbox, + datetime, + limit, + offset, + properties, + filter_, + filter_lang, + ) + return JSONResponse(content=payload, media_type="application/geo+json") + + +@router.get("/collections/{collection_id}/items/{fid}") +def item( + request: Request, + user: viewer_dependency, + session: session_dependency, + collection_id: str, + fid: int, +): + payload = get_item(request, session, collection_id, fid) + return JSONResponse(content=payload, media_type="application/geo+json") diff --git a/api/ogc/schemas.py b/api/ogc/schemas.py new file mode 100644 index 00000000..ed87e183 --- /dev/null +++ b/api/ogc/schemas.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from pydantic import BaseModel, Field + + +class Link(BaseModel): + href: str + rel: str + type: Optional[str] = None + title: Optional[str] = None + + +class LandingPage(BaseModel): + title: str + description: str + links: List[Link] + + +class Conformance(BaseModel): + conformsTo: List[str] = Field(default_factory=list) + + +class CollectionExtentSpatial(BaseModel): + bbox: List[List[float]] + crs: str + + +class CollectionExtentTemporal(BaseModel): + interval: List[List[Optional[str]]] + trs: Optional[str] = None + + +class CollectionExtent(BaseModel): + spatial: Optional[CollectionExtentSpatial] = None + temporal: Optional[CollectionExtentTemporal] = None + + +class Collection(BaseModel): + id: str + title: str + description: Optional[str] = None + itemType: str = "feature" + crs: Optional[List[str]] = None + links: List[Link] + extent: Optional[CollectionExtent] = None + + +class Collections(BaseModel): + links: List[Link] + collections: List[Collection] + + +class Feature(BaseModel): + type: str = "Feature" + id: str | int + geometry: dict[str, Any] + properties: dict[str, Any] + + +class FeatureCollection(BaseModel): + type: str = "FeatureCollection" + features: List[Feature] + links: List[Link] + numberMatched: int + numberReturned: int diff --git a/core/initializers.py b/core/initializers.py index 02ecc968..330ade9f 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -125,6 +125,7 @@ def register_routes(app): from api.search import router as search_router from api.geospatial import router as geospatial_router from api.ngwmn import router as ngwmn_router + from api.ogc.router import router as ogc_router app.include_router(asset_router) app.include_router(admin_auth_router) @@ -132,6 +133,7 @@ def register_routes(app): app.include_router(contact_router) app.include_router(geospatial_router) app.include_router(group_router) + app.include_router(ogc_router) app.include_router(lexicon_router) app.include_router(location_router) app.include_router(observation_router) diff --git a/tests/conftest.py b/tests/conftest.py index 6bc4a5dc..979778cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,20 @@ def _reset_schema() -> None: session.execute(text("DROP SCHEMA public CASCADE")) session.execute(text("CREATE SCHEMA public")) session.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + session.execute( + text( + """ + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_read') THEN + EXECUTE 'GRANT USAGE ON SCHEMA public TO app_read'; + EXECUTE 'GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_read'; + EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_read'; + END IF; + END $$; + """ + ) + ) session.commit() diff --git a/tests/test_ogc.py b/tests/test_ogc.py new file mode 100644 index 00000000..88a6a8cb --- /dev/null +++ b/tests/test_ogc.py @@ -0,0 +1,110 @@ +# =============================================================================== +# 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. +# =============================================================================== +import pytest + +from core.dependencies import ( + admin_function, + editor_function, + amp_admin_function, + amp_editor_function, + viewer_function, + amp_viewer_function, +) +from main import app +from tests import client, override_authentication + + +@pytest.fixture(scope="module", autouse=True) +def override_authentication_dependency_fixture(): + app.dependency_overrides[admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[viewer_function] = override_authentication() + app.dependency_overrides[amp_admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_viewer_function] = override_authentication() + + yield + + app.dependency_overrides = {} + + +def test_ogc_landing(): + response = client.get("/ogc") + assert response.status_code == 200 + payload = response.json() + assert payload["title"] + assert any(link["rel"] == "self" for link in payload["links"]) + + +def test_ogc_conformance(): + response = client.get("/ogc/conformance") + assert response.status_code == 200 + payload = response.json() + assert "conformsTo" in payload + assert any("ogcapi-features" in item for item in payload["conformsTo"]) + + +def test_ogc_collections(): + response = client.get("/ogc/collections") + assert response.status_code == 200 + payload = response.json() + ids = {collection["id"] for collection in payload["collections"]} + assert {"locations", "wells", "springs"}.issubset(ids) + + +def test_ogc_locations_items_bbox(location): + bbox = "-107.95,33.80,-107.94,33.81" + response = client.get(f"/ogc/collections/locations/items?bbox={bbox}") + assert response.status_code == 200 + payload = response.json() + assert payload["type"] == "FeatureCollection" + assert payload["numberReturned"] >= 1 + + +def test_ogc_wells_items_and_item(water_well_thing): + response = client.get("/ogc/collections/wells/items?properties=name='Test Well'") + assert response.status_code == 200 + payload = response.json() + assert payload["numberReturned"] >= 1 + feature = payload["features"][0] + assert feature["properties"]["name"] == "Test Well" + + response = client.get(f"/ogc/collections/wells/items/{water_well_thing.id}") + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == water_well_thing.id + + +def test_ogc_polygon_within_filter(location): + polygon = "POLYGON((-107.95 33.80,-107.94 33.80,-107.94 33.81,-107.95 33.81,-107.95 33.80))" + response = client.get( + "/ogc/collections/locations/items", + params={ + "filter": f"WITHIN(geometry,{polygon})", + "filter-lang": "cql2-text", + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["numberReturned"] >= 1 From 48420871088b929eb8e7e8ff2d58745e904b204d Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Wed, 14 Jan 2026 16:39:14 -0700 Subject: [PATCH 2/9] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f7804d8..4c6f5639 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ curl "http://localhost:8000/ogc/collections/locations/items?filter=WITHIN(geomet Basic property filters are supported with `properties`: ```bash -curl "http://localhost:8000/ogc/collections/wells/items?properties=thing_type='water well' AND well_depth=100" +curl "http://localhost:8000/ogc/collections/wells/items?properties=thing_type='water well' AND well_depth>=100 AND well_depth<=200" curl "http://localhost:8000/ogc/collections/wells/items?properties=well_purposes IN ('domestic','irrigation')" curl "http://localhost:8000/ogc/collections/wells/items?properties=well_casing_materials='PVC'" curl "http://localhost:8000/ogc/collections/wells/items?properties=well_screen_type='Steel'" From 155c486690353bb9fb68ed5006cab6af18c2a870 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 14 Jan 2026 16:44:28 -0700 Subject: [PATCH 3/9] feat: add utility functions for splitting clauses and field-value pairs in properties filter --- api/ogc/features.py | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/api/ogc/features.py b/api/ogc/features.py index e725c102..0f37a5ff 100644 --- a/api/ogc/features.py +++ b/api/ogc/features.py @@ -66,6 +66,37 @@ def _coerce_value(value: str) -> Any: return stripped +def _split_and_clauses(properties: str) -> list[str]: + normalized = " ".join(properties.split()) + lower = normalized.lower() + clauses = [] + start = 0 + needle = " and " + while True: + idx = lower.find(needle, start) + if idx == -1: + clause = normalized[start:].strip() + if clause: + clauses.append(clause) + break + clause = normalized[start:idx].strip() + if clause: + clauses.append(clause) + start = idx + len(needle) + return clauses + + +def _split_field_and_value(text: str) -> tuple[str | None, str | None]: + left, sep, right = text.partition("=") + if not sep: + return None, None + field = left.strip() + value = right.strip() + if not field or not value: + return None, None + return field, value + + def _apply_properties_filter( query, properties: str, @@ -73,11 +104,7 @@ def _apply_properties_filter( relationship_map: Dict[str, Any] | None = None, ): relationship_map = relationship_map or {} - clauses = [ - clause.strip() - for clause in re.split(r"\s+AND\s+", properties, flags=re.IGNORECASE) - if clause.strip() - ] + clauses = _split_and_clauses(properties) for clause in clauses: in_match = re.match( r"^\s*(\w+)\s+IN\s+\((.+)\)\s*$", clause, flags=re.IGNORECASE @@ -98,10 +125,8 @@ def _apply_properties_filter( column_map[field].in_([_coerce_value(v) for v in values]) ) continue - eq_match = re.match(r"^\s*(\w+)\s*=\s*(.+)\s*$", clause) - if eq_match: - field = eq_match.group(1) - value = eq_match.group(2) + field, value = _split_field_and_value(clause) + if field and value: if field in relationship_map: query = query.where(relationship_map[field]([_coerce_value(value)])) continue From 96fd5b08940976512fa1fd86ccdc548d0b03e660 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 14 Jan 2026 16:46:18 -0700 Subject: [PATCH 4/9] feat: enhance _thing_query to support eager loading of well relationships --- api/ogc/features.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/api/ogc/features.py b/api/ogc/features.py index 0f37a5ff..fb2c1b83 100644 --- a/api/ogc/features.py +++ b/api/ogc/features.py @@ -13,7 +13,7 @@ ST_Within, ) from sqlalchemy import exists, func, select -from sqlalchemy.orm import aliased +from sqlalchemy.orm import aliased, selectinload from core.constants import SRID_WGS84 from db.location import Location, LocationThingAssociation @@ -177,10 +177,10 @@ def _location_query(): ) -def _thing_query(thing_type: str): +def _thing_query(thing_type: str, eager_well_relationships: bool = False): lta_alias = aliased(LocationThingAssociation) latest_assoc = _latest_location_subquery() - return ( + query = ( select( Thing, ST_AsGeoJSON(Location.point).label("geojson"), @@ -194,6 +194,13 @@ def _thing_query(thing_type: str): ) .where(Thing.thing_type == thing_type) ) + if eager_well_relationships: + query = query.options( + selectinload(Thing.well_purposes), + selectinload(Thing.well_casing_materials), + selectinload(Thing.screens), + ) + return query def _apply_bbox_filter(query, bbox: str): @@ -310,7 +317,7 @@ def get_items( datetime_column = Location.created_at relationship_map = {} elif collection_id == "wells": - query = _thing_query("water well") + query = _thing_query("water well", eager_well_relationships=True) column_map = { "id": Thing.id, "name": Thing.name, @@ -416,7 +423,9 @@ def get_item( if collection_id == "locations": query = _location_query().where(Location.id == fid) elif collection_id == "wells": - query = _thing_query("water well").where(Thing.id == fid) + query = _thing_query("water well", eager_well_relationships=True).where( + Thing.id == fid + ) elif collection_id == "springs": query = _thing_query("spring").where(Thing.id == fid) else: From 44e333b238b2fc86d2ea52f4accae1c48235a0d6 Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Wed, 14 Jan 2026 16:50:43 -0700 Subject: [PATCH 5/9] Update api/ogc/collections.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/ogc/collections.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/ogc/collections.py b/api/ogc/collections.py index 85067d2d..af196213 100644 --- a/api/ogc/collections.py +++ b/api/ogc/collections.py @@ -15,19 +15,16 @@ "title": "Locations", "description": "Sample locations", "itemType": "feature", - "priority": "P0", }, "wells": { "title": "Wells", "description": "Things filtered to water wells", "itemType": "feature", - "priority": "P0", }, "springs": { "title": "Springs", "description": "Things filtered to springs", "itemType": "feature", - "priority": "P1", }, } From 8b9eb2e8b0a61aa057c287358ca1bb3af15278c3 Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Wed, 14 Jan 2026 16:55:39 -0700 Subject: [PATCH 6/9] Update api/ogc/features.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/ogc/features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/ogc/features.py b/api/ogc/features.py index fb2c1b83..af3e1293 100644 --- a/api/ogc/features.py +++ b/api/ogc/features.py @@ -164,7 +164,7 @@ def _latest_location_subquery(): LocationThingAssociation.thing_id, func.max(LocationThingAssociation.effective_start).label("max_start"), ) - .where(LocationThingAssociation.effective_end == None) + .where(LocationThingAssociation.effective_end is None) .group_by(LocationThingAssociation.thing_id) .subquery() ) From 3b88ce70be02786734c3320211ae74c533d38789 Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Wed, 14 Jan 2026 16:57:55 -0700 Subject: [PATCH 7/9] Update api/ogc/router.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/ogc/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/ogc/router.py b/api/ogc/router.py index b17a1d65..bfaa36c6 100644 --- a/api/ogc/router.py +++ b/api/ogc/router.py @@ -14,7 +14,7 @@ router = APIRouter(prefix="/ogc", tags=["ogc"]) -@router.get("") +@router.get("/") def landing_page(request: Request) -> LandingPage: base = str(request.base_url).rstrip("/") return { From 0191197609c1ef995972f57d9742b95c06f0d6ab Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Wed, 14 Jan 2026 16:58:41 -0700 Subject: [PATCH 8/9] Update api/ogc/features.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/ogc/features.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/api/ogc/features.py b/api/ogc/features.py index af3e1293..3b11575d 100644 --- a/api/ogc/features.py +++ b/api/ogc/features.py @@ -35,11 +35,9 @@ def _parse_datetime(value: str) -> datetime: if text.endswith("Z"): text = text[:-1] + "+00:00" parsed = datetime.fromisoformat(text) - if isinstance(parsed, datetime): - if parsed.tzinfo is None: - return parsed.replace(tzinfo=timezone.utc) - return parsed - return datetime.combine(parsed, datetime.min.time(), tzinfo=timezone.utc) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed def _parse_datetime_range(value: str) -> Tuple[datetime | None, datetime | None]: From 4846b0db36dcef34166c357d45cb6ad61ec5b9ad Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 14 Jan 2026 17:36:29 -0700 Subject: [PATCH 9/9] feat: improve _split_and_clauses function to handle quoted strings and enhance clause splitting logic --- api/ogc/features.py | 50 ++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/api/ogc/features.py b/api/ogc/features.py index 3b11575d..7fef38e8 100644 --- a/api/ogc/features.py +++ b/api/ogc/features.py @@ -65,22 +65,40 @@ def _coerce_value(value: str) -> Any: def _split_and_clauses(properties: str) -> list[str]: - normalized = " ".join(properties.split()) - lower = normalized.lower() + lower = properties.lower() clauses = [] - start = 0 - needle = " and " - while True: - idx = lower.find(needle, start) - if idx == -1: - clause = normalized[start:].strip() - if clause: - clauses.append(clause) - break - clause = normalized[start:idx].strip() - if clause: - clauses.append(clause) - start = idx + len(needle) + buffer = [] + in_single_quote = False + in_double_quote = False + idx = 0 + while idx < len(properties): + char = properties[idx] + if char == "'" and not in_double_quote: + in_single_quote = not in_single_quote + buffer.append(char) + idx += 1 + continue + if char == '"' and not in_single_quote: + in_double_quote = not in_double_quote + buffer.append(char) + idx += 1 + continue + if not in_single_quote and not in_double_quote: + if lower[idx : idx + 3] == "and": + before = properties[idx - 1] if idx > 0 else " " + after = properties[idx + 3] if idx + 3 < len(properties) else " " + if before.isspace() and after.isspace(): + clause = "".join(buffer).strip() + if clause: + clauses.append(clause) + buffer = [] + idx += 3 + continue + buffer.append(char) + idx += 1 + clause = "".join(buffer).strip() + if clause: + clauses.append(clause) return clauses @@ -162,7 +180,7 @@ def _latest_location_subquery(): LocationThingAssociation.thing_id, func.max(LocationThingAssociation.effective_start).label("max_start"), ) - .where(LocationThingAssociation.effective_end is None) + .where(LocationThingAssociation.effective_end.is_(None)) .group_by(LocationThingAssociation.thing_id) .subquery() )