Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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:

Expand Down
1 change: 0 additions & 1 deletion export_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,3 @@ def main():

if __name__ == "__main__":
main()

122 changes: 107 additions & 15 deletions main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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=[
Expand All @@ -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,
Expand All @@ -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",
},
],
}
]
Expand All @@ -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
Expand All @@ -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 = []
Expand All @@ -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")


Expand All @@ -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",
},
],
}

Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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))
39 changes: 39 additions & 0 deletions tests/test_health.py
Original file line number Diff line number Diff line change
@@ -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"}
31 changes: 31 additions & 0 deletions tests/test_items.py
Original file line number Diff line number Diff line change
@@ -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")
3 changes: 0 additions & 3 deletions tests/test_openapi.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import json
from pathlib import Path

from main import app


Expand Down
Loading