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 a1acc35..e50f576 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,8 @@ from typing import List, Literal, Optional, Dict, Any +import os 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 +24,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 +64,19 @@ class CollectionsResponse(BaseModel): redoc_url="/redoc", ) +# 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( features=[ @@ -84,6 +103,17 @@ def root(): return RedirectResponse(url="/docs") +@app.get("/healthz", include_in_schema=False) +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, @@ -97,8 +127,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", + }, ], } ] @@ -119,6 +157,14 @@ 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 @@ -129,14 +175,34 @@ 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 + 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] + page = feats[offset : offset + limit] base = "/collections/addresses/items" params = [] @@ -149,14 +215,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") @@ -174,8 +258,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 new file mode 100644 index 0000000..ab2edf2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +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_health.py b/tests/test_health.py new file mode 100644 index 0000000..1691d33 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,39 @@ +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 == "*" + + +def test_readyz_ok(): + r = client.get("/readyz") + assert r.status_code == 200 + assert r.json() == {"status": "ready"} diff --git a/tests/test_items.py b/tests/test_items.py new file mode 100644 index 0000000..7454f6f --- /dev/null +++ b/tests/test_items.py @@ -0,0 +1,31 @@ +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(link["rel"] == "self" for link 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") 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