From 12a4d30949fc8aa60c1774bfcaf597cd5d3dea6c Mon Sep 17 00:00:00 2001 From: alpha912 Date: Sun, 7 Sep 2025 22:53:48 +0000 Subject: [PATCH 1/4] feat(items): add filters for pin, city, ulb_lgd, and digipin; improve tests for media type, bbox, and pagination --- main.py | 18 ++++++++++++++++++ tests/conftest.py | 8 ++++++++ tests/test_items.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_items.py diff --git a/main.py b/main.py index a1acc35..14853a6 100644 --- a/main.py +++ b/main.py @@ -119,6 +119,10 @@ def items( description="minLon,minLat,maxLon,maxLat (comma-separated) to filter by bounding box", examples={"blr": {"summary": "Bengaluru bbox", "value": "77.4,12.8,77.8,13.1"}}, ), + pin: Optional[str] = Query(None, description="Filter by PIN code (exact match)"), + city: Optional[str] = Query(None, description="Filter by city (case-insensitive)"), + ulb_lgd: Optional[str] = Query(None, description="Filter by ULB LGD code (exact match)"), + digipin: Optional[str] = Query(None, description="Filter by DIGIPIN (matches primary/secondary)"), ): # Filter by bbox if provided feats = FEATURES.features @@ -135,6 +139,20 @@ def items( if (minx <= f.geometry.coordinates[0] <= maxx) and (miny <= f.geometry.coordinates[1] <= maxy) ] + # Attribute filtering + if pin: + feats = [f for f in feats if f.properties.pin == pin] + if city: + feats = [f for f in feats if f.properties.city.lower() == city.lower()] + if ulb_lgd: + feats = [f for f in feats if f.properties.ulb_lgd == ulb_lgd] + if digipin: + feats = [ + f + for f in feats + if f.properties.primary_digipin == digipin or (f.properties.secondary_digipin == digipin) + ] + total = len(feats) page = feats[offset: offset + limit] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c705975 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import sys +from pathlib import Path + +# Ensure the project root (containing main.py) is importable in tests +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + diff --git a/tests/test_items.py b/tests/test_items.py new file mode 100644 index 0000000..4085827 --- /dev/null +++ b/tests/test_items.py @@ -0,0 +1,32 @@ +from fastapi.testclient import TestClient +from main import app + + +client = TestClient(app) + + +def test_items_geojson_media_type_and_pagination_links(): + r = client.get("/collections/addresses/items", params={"limit": 1, "offset": 0}) + assert r.status_code == 200 + assert r.headers.get("content-type").startswith("application/geo+json") + data = r.json() + assert data["type"] == "FeatureCollection" + # With limit=1 and at least one feature total, next link should be present if more than one + assert any(l["rel"] == "self" for l in data.get("links", [])) + + +def test_items_pin_filter_returns_match(): + r = client.get("/collections/addresses/items", params={"pin": "560008"}) + assert r.status_code == 200 + data = r.json() + assert data["numberMatched"] >= 1 + assert data["numberReturned"] >= 1 + for f in data["features"]: + assert f["properties"]["pin"] == "560008" + + +def test_items_invalid_bbox_400(): + r = client.get("/collections/addresses/items", params={"bbox": "not,a,bbox"}) + assert r.status_code == 400 + assert r.json()["error"].startswith("Invalid bbox") + From 608f2283174ac18be86aa0a8d682aac86b813dbd Mon Sep 17 00:00:00 2001 From: alpha912 Date: Mon, 8 Sep 2025 06:17:17 +0000 Subject: [PATCH 2/4] feat: add CORS and /healthz; tests; fix lint issues --- README.md | 3 +- export_openapi.py | 1 - main.py | 99 +++++++++++++++++++++++++++++++++++-------- tests/conftest.py | 1 - tests/test_health.py | 33 +++++++++++++++ tests/test_items.py | 3 +- tests/test_openapi.py | 3 -- 7 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 tests/test_health.py diff --git a/README.md b/README.md index 11087bd..8bea964 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ pip install -r requirements.txt uvicorn main:app --reload --port 8000 # Open http://localhost:8000/collections, /collections/addresses, /collections/addresses/items, /conformance # API docs: Swagger UI at /docs, ReDoc at /redoc, OpenAPI JSON at /openapi.json +``` OpenAPI export: + ```bash python export_openapi.py # writes openapi.json in repo root ``` @@ -23,7 +25,6 @@ Query parameters (items endpoint): - `limit` (default 100): maximum features returned - `offset` (default 0): pagination index; response includes `self`/`next`/`prev` links - `bbox`: `minLon,minLat,maxLon,maxLat` filter -``` Docker: diff --git a/export_openapi.py b/export_openapi.py index d6593b2..658b458 100644 --- a/export_openapi.py +++ b/export_openapi.py @@ -12,4 +12,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/main.py b/main.py index 14853a6..e72505f 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ from typing import List, Literal, Optional, Dict, Any from fastapi import FastAPI, Query +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, RedirectResponse from pydantic import BaseModel, Field from fastapi.openapi.utils import get_openapi @@ -22,8 +23,12 @@ class AddressProperties(BaseModel): primary_digipin: str secondary_digipin: Optional[str] = None ulpin: Optional[str] = None - entrance_point_source: Optional[str] = Field(default=None, description="survey|imagery|crowd|post") - quality: Optional[str] = Field(default=None, description="MunicipalityVerified|GeoVerified|CrowdPending") + entrance_point_source: Optional[str] = Field( + default=None, description="survey|imagery|crowd|post" + ) + quality: Optional[str] = Field( + default=None, description="MunicipalityVerified|GeoVerified|CrowdPending" + ) class Feature(BaseModel): @@ -58,6 +63,15 @@ class CollectionsResponse(BaseModel): redoc_url="/redoc", ) +# CORS: allow all origins for demo/reference usage +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + FEATURES: FeatureCollection = FeatureCollection( features=[ @@ -84,6 +98,11 @@ def root(): return RedirectResponse(url="/docs") +@app.get("/healthz", include_in_schema=False) +def healthz(): + return {"status": "ok"} + + @app.get( "/collections", response_model=CollectionsResponse, @@ -97,8 +116,16 @@ def collections(): "id": "addresses", "title": "Addresses", "links": [ - {"rel": "self", "type": "application/json", "href": "/collections/addresses"}, - {"rel": "items", "type": "application/geo+json", "href": "/collections/addresses/items"}, + { + "rel": "self", + "type": "application/json", + "href": "/collections/addresses", + }, + { + "rel": "items", + "type": "application/geo+json", + "href": "/collections/addresses/items", + }, ], } ] @@ -121,8 +148,12 @@ def items( ), pin: Optional[str] = Query(None, description="Filter by PIN code (exact match)"), city: Optional[str] = Query(None, description="Filter by city (case-insensitive)"), - ulb_lgd: Optional[str] = Query(None, description="Filter by ULB LGD code (exact match)"), - digipin: Optional[str] = Query(None, description="Filter by DIGIPIN (matches primary/secondary)"), + ulb_lgd: Optional[str] = Query( + None, description="Filter by ULB LGD code (exact match)" + ), + digipin: Optional[str] = Query( + None, description="Filter by DIGIPIN (matches primary/secondary)" + ), ): # Filter by bbox if provided feats = FEATURES.features @@ -133,10 +164,15 @@ def items( raise ValueError minx, miny, maxx, maxy = parts except Exception: - return JSONResponse({"error": "Invalid bbox. Expected 'minLon,minLat,maxLon,maxLat'"}, status_code=400) + return JSONResponse( + {"error": "Invalid bbox. Expected 'minLon,minLat,maxLon,maxLat'"}, + status_code=400, + ) feats = [ - f for f in feats - if (minx <= f.geometry.coordinates[0] <= maxx) and (miny <= f.geometry.coordinates[1] <= maxy) + f + for f in feats + if (minx <= f.geometry.coordinates[0] <= maxx) + and (miny <= f.geometry.coordinates[1] <= maxy) ] # Attribute filtering @@ -150,11 +186,12 @@ def items( feats = [ f for f in feats - if f.properties.primary_digipin == digipin or (f.properties.secondary_digipin == digipin) + if f.properties.primary_digipin == digipin + or (f.properties.secondary_digipin == digipin) ] total = len(feats) - page = feats[offset: offset + limit] + page = feats[offset : offset + limit] base = "/collections/addresses/items" params = [] @@ -167,14 +204,32 @@ def items( links: List[Link] = [Link(rel="self", href=self_href, type="application/geo+json")] if offset + limit < total: next_offset = offset + limit - next_params = [p for p in params if not p.startswith("offset=")] + [f"offset={next_offset}"] - links.append(Link(rel="next", href=base + "?" + "&".join(next_params), type="application/geo+json")) + next_params = [p for p in params if not p.startswith("offset=")] + [ + f"offset={next_offset}" + ] + links.append( + Link( + rel="next", + href=base + "?" + "&".join(next_params), + type="application/geo+json", + ) + ) if offset > 0: prev_offset = max(0, offset - limit) - prev_params = [p for p in params if not p.startswith("offset=")] + [f"offset={prev_offset}"] - links.append(Link(rel="prev", href=base + "?" + "&".join(prev_params), type="application/geo+json")) + prev_params = [p for p in params if not p.startswith("offset=")] + [ + f"offset={prev_offset}" + ] + links.append( + Link( + rel="prev", + href=base + "?" + "&".join(prev_params), + type="application/geo+json", + ) + ) - coll = FeatureCollection(features=page, links=links, numberMatched=total, numberReturned=len(page)) + coll = FeatureCollection( + features=page, links=links, numberMatched=total, numberReturned=len(page) + ) return JSONResponse(coll.model_dump(), media_type="application/geo+json") @@ -192,8 +247,16 @@ def describe_addresses() -> Dict[str, Any]: }, "itemType": "feature", "links": [ - {"rel": "self", "type": "application/json", "href": "/collections/addresses"}, - {"rel": "items", "type": "application/geo+json", "href": "/collections/addresses/items"}, + { + "rel": "self", + "type": "application/json", + "href": "/collections/addresses", + }, + { + "rel": "items", + "type": "application/geo+json", + "href": "/collections/addresses/items", + }, ], } diff --git a/tests/conftest.py b/tests/conftest.py index c705975..ab2edf2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,4 +5,3 @@ ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) - diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..23b7144 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,33 @@ +from fastapi.testclient import TestClient +from main import app + + +client = TestClient(app) + + +def test_healthz_ok(): + r = client.get("/healthz") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} + + +def test_cors_header_on_request_with_origin(): + # When Origin header present, CORS middleware should include ACAO + r = client.get("/collections", headers={"Origin": "http://example.com"}) + assert r.status_code == 200 + assert r.headers.get("access-control-allow-origin") == "*" + + +def test_cors_preflight_options(): + r = client.options( + "/collections", + headers={ + "Origin": "http://example.com", + "Access-Control-Request-Method": "GET", + }, + ) + assert r.status_code in (200, 204) + # Starlette may return 200 with allow headers + assert r.headers.get("access-control-allow-origin") == "*" + allow_methods = r.headers.get("access-control-allow-methods", "") + assert "GET" in allow_methods or allow_methods == "*" diff --git a/tests/test_items.py b/tests/test_items.py index 4085827..7454f6f 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -12,7 +12,7 @@ def test_items_geojson_media_type_and_pagination_links(): data = r.json() assert data["type"] == "FeatureCollection" # With limit=1 and at least one feature total, next link should be present if more than one - assert any(l["rel"] == "self" for l in data.get("links", [])) + assert any(link["rel"] == "self" for link in data.get("links", [])) def test_items_pin_filter_returns_match(): @@ -29,4 +29,3 @@ def test_items_invalid_bbox_400(): r = client.get("/collections/addresses/items", params={"bbox": "not,a,bbox"}) assert r.status_code == 400 assert r.json()["error"].startswith("Invalid bbox") - diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 1098d3c..23f65e8 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -1,6 +1,3 @@ -import json -from pathlib import Path - from main import app From 55ee627a77e10cda875c2a50723c31b258b7b05c Mon Sep 17 00:00:00 2001 From: alpha912 Date: Mon, 8 Sep 2025 06:18:08 +0000 Subject: [PATCH 3/4] feat: add /readyz and env-based CORS origins --- main.py | 20 ++++++++++++-------- tests/test_health.py | 6 ++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/main.py b/main.py index e72505f..2796428 100644 --- a/main.py +++ b/main.py @@ -63,14 +63,12 @@ class CollectionsResponse(BaseModel): redoc_url="/redoc", ) -# CORS: allow all origins for demo/reference usage -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=False, - allow_methods=["*"], - allow_headers=["*"], -) +import os + +# CORS: configurable via env, default allow all +_origins = os.getenv("CORS_ALLOW_ORIGINS", "*").strip() +_allow_origins = ["*"] if _origins == "*" else [o.strip() for o in _origins.split(",") if o.strip()] +app.add_middleware(CORSMiddleware, allow_origins=_allow_origins, allow_credentials=False, allow_methods=["*"], allow_headers=["*"]) FEATURES: FeatureCollection = FeatureCollection( @@ -103,6 +101,12 @@ def healthz(): return {"status": "ok"} +@app.get("/readyz", include_in_schema=False) +def readyz(): + # In this demo app, readiness is same as liveness + return {"status": "ready"} + + @app.get( "/collections", response_model=CollectionsResponse, diff --git a/tests/test_health.py b/tests/test_health.py index 23b7144..1691d33 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -31,3 +31,9 @@ def test_cors_preflight_options(): assert r.headers.get("access-control-allow-origin") == "*" allow_methods = r.headers.get("access-control-allow-methods", "") assert "GET" in allow_methods or allow_methods == "*" + + +def test_readyz_ok(): + r = client.get("/readyz") + assert r.status_code == 200 + assert r.json() == {"status": "ready"} From 4a782338d93c57f1eb1da583a3fbd19feb3db25d Mon Sep 17 00:00:00 2001 From: alpha912 Date: Mon, 8 Sep 2025 06:21:09 +0000 Subject: [PATCH 4/4] chore(format): black format after import move --- main.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 2796428..e50f576 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ from typing import List, Literal, Optional, Dict, Any +import os from fastapi import FastAPI, Query from fastapi.middleware.cors import CORSMiddleware @@ -63,12 +64,18 @@ class CollectionsResponse(BaseModel): redoc_url="/redoc", ) -import os - # CORS: configurable via env, default allow all _origins = os.getenv("CORS_ALLOW_ORIGINS", "*").strip() -_allow_origins = ["*"] if _origins == "*" else [o.strip() for o in _origins.split(",") if o.strip()] -app.add_middleware(CORSMiddleware, allow_origins=_allow_origins, allow_credentials=False, allow_methods=["*"], allow_headers=["*"]) +_allow_origins = ( + ["*"] if _origins == "*" else [o.strip() for o in _origins.split(",") if o.strip()] +) +app.add_middleware( + CORSMiddleware, + allow_origins=_allow_origins, + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) FEATURES: FeatureCollection = FeatureCollection(