Skip to content

Commit b7c6d01

Browse files
committed
new endpoint for creating vector layers with geojson feature collections
1 parent 2ec67b4 commit b7c6d01

3 files changed

Lines changed: 364 additions & 0 deletions

File tree

backend/app/api/api_v1/endpoints/vector_layers.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,86 @@ def download_vector_layer(
327327
)
328328

329329

330+
@router.post(
331+
"/geojson",
332+
response_model=schemas.vector_layer.VectorLayerFeatureCollection,
333+
status_code=status.HTTP_201_CREATED,
334+
)
335+
def create_vector_layer_from_geojson(
336+
vector_layer_in: schemas.vector_layer.VectorLayerCreate,
337+
current_user: models.User = Depends(deps.get_current_approved_user),
338+
project: models.Project = Depends(deps.can_read_write_project),
339+
db: Session = Depends(deps.get_db),
340+
) -> Any:
341+
"""Create a vector layer from a GeoJSON FeatureCollection payload.
342+
343+
Args:
344+
vector_layer_in: VectorLayerCreate schema with layer_name and geojson
345+
current_user: Current authenticated user
346+
project: Project the vector layer belongs to
347+
db: Database session
348+
349+
Returns:
350+
VectorLayerFeatureCollection: Created vector layer as a FeatureCollection with metadata
351+
352+
Raises:
353+
HTTPException: If validation fails or layer creation fails
354+
"""
355+
# Validate coordinates are within valid geographic ranges
356+
validate_geojson_coordinates(vector_layer_in.geojson)
357+
358+
# Check that FeatureCollection has at least one feature
359+
if not vector_layer_in.geojson.features or len(vector_layer_in.geojson.features) == 0:
360+
raise HTTPException(
361+
status_code=status.HTTP_400_BAD_REQUEST,
362+
detail="FeatureCollection must contain at least one feature",
363+
)
364+
365+
# Convert FeatureCollection to GeoDataFrame
366+
try:
367+
gdf = feature_collection_to_geodataframe(vector_layer_in.geojson)
368+
except Exception:
369+
logger.exception("Failed to convert FeatureCollection to GeoDataFrame")
370+
raise HTTPException(
371+
status_code=status.HTTP_400_BAD_REQUEST,
372+
detail="Unable to process GeoJSON FeatureCollection",
373+
)
374+
375+
# Create vector layer records in database
376+
try:
377+
features = crud.vector_layer.create_with_project(
378+
db,
379+
file_name=vector_layer_in.layer_name,
380+
gdf=gdf,
381+
project_id=project.id,
382+
)
383+
except Exception:
384+
logger.exception("Failed to create vector layer in database")
385+
raise HTTPException(
386+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
387+
detail="Unable to create vector layer",
388+
)
389+
390+
# Build response with metadata
391+
if len(features) > 0:
392+
layer_id = features[0].properties.get("layer_id", "undefined")
393+
feature_collection = {
394+
"type": "FeatureCollection",
395+
"features": features,
396+
"metadata": {
397+
"preview_url": f"{settings.API_DOMAIN}{settings.STATIC_DIR}"
398+
f"/projects/{project.id}/vector/{layer_id}"
399+
f"/preview.png",
400+
},
401+
}
402+
return feature_collection
403+
else:
404+
raise HTTPException(
405+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
406+
detail="No features created",
407+
)
408+
409+
330410
@router.delete("/{layer_id}", status_code=status.HTTP_200_OK)
331411
def delete_vector_layer(
332412
layer_id: str,
@@ -349,6 +429,69 @@ def delete_vector_layer(
349429
)
350430

351431

432+
def feature_collection_to_geodataframe(
433+
geojson: schemas.vector_layer.FeatureCollection,
434+
) -> gpd.GeoDataFrame:
435+
"""
436+
Converts a GeoJSON FeatureCollection to a GeoDataFrame.
437+
438+
Args:
439+
geojson: The FeatureCollection to convert
440+
441+
Returns:
442+
gpd.GeoDataFrame: GeoDataFrame with features from the FeatureCollection
443+
"""
444+
return gpd.GeoDataFrame.from_features(geojson.features, crs="EPSG:4326")
445+
446+
447+
def validate_geojson_coordinates(geojson: schemas.vector_layer.FeatureCollection) -> None:
448+
"""
449+
Validates that all coordinates in a GeoJSON FeatureCollection are within valid geographic ranges.
450+
Raises HTTPException if invalid coordinates are found.
451+
452+
Args:
453+
geojson: The FeatureCollection to validate
454+
455+
Raises:
456+
HTTPException: If coordinates are outside valid geographic ranges
457+
"""
458+
for i, feature in enumerate(geojson.features):
459+
if hasattr(feature.geometry, "coordinates"):
460+
# Handle different geometry types
461+
if feature.geometry.type == "Point":
462+
coords = [feature.geometry.coordinates]
463+
elif feature.geometry.type in ["LineString", "MultiPoint"]:
464+
coords = feature.geometry.coordinates
465+
elif feature.geometry.type in ["Polygon", "MultiLineString"]:
466+
# Flatten polygon/multilinestring coordinates
467+
coords = []
468+
for ring in feature.geometry.coordinates:
469+
coords.extend(ring)
470+
elif feature.geometry.type == "MultiPolygon":
471+
# Flatten multipolygon coordinates
472+
coords = []
473+
for polygon in feature.geometry.coordinates:
474+
for ring in polygon:
475+
coords.extend(ring)
476+
else:
477+
continue # Skip unknown geometry types
478+
479+
# Validate each coordinate pair
480+
for coord in coords:
481+
if len(coord) >= 2:
482+
lng, lat = coord[0], coord[1]
483+
if not (-180 <= lng <= 180):
484+
raise HTTPException(
485+
status_code=status.HTTP_400_BAD_REQUEST,
486+
detail=f"Invalid longitude {lng} in feature {i}. Must be between -180 and 180.",
487+
)
488+
if not (-90 <= lat <= 90):
489+
raise HTTPException(
490+
status_code=status.HTTP_400_BAD_REQUEST,
491+
detail=f"Invalid latitude {lat} in feature {i}. Must be between -90 and 90.",
492+
)
493+
494+
352495
def get_preview_url(project_id: str, layer_id: str) -> str:
353496
"""Returns URL for vector layer preview image.
354497

backend/app/tests/api/api_v1/test_vector_layers.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from app.schemas.vector_layer import VectorLayerFeatureCollection
1212
from app.tests.utils.project import create_project
1313
from app.tests.utils.project_member import create_project_member
14+
from app.tests.utils.utils import get_geojson_feature_collection
1415
from app.tests.utils.vector_layers import create_feature_collection
1516

1617

@@ -340,3 +341,177 @@ def test_remove_vector_layer_without_project_role(
340341
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/{layer_id}"
341342
)
342343
assert response.status_code == status.HTTP_404_NOT_FOUND
344+
345+
346+
def test_create_vector_layer_from_geojson_with_point(
347+
client: TestClient, db: Session, normal_user_access_token: str
348+
) -> None:
349+
current_user = get_current_user(db, normal_user_access_token)
350+
project = create_project(db, owner_id=current_user.id)
351+
vector_layer_data = get_geojson_feature_collection("point")
352+
353+
response = client.post(
354+
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/geojson",
355+
json=vector_layer_data,
356+
)
357+
assert response.status_code == status.HTTP_201_CREATED
358+
response_data = response.json()
359+
assert response_data["type"] == "FeatureCollection"
360+
assert len(response_data["features"]) == 1
361+
assert response_data["features"][0]["geometry"]["type"] == "Point"
362+
assert response_data["features"][0]["properties"]["layer_name"] == "Point Example"
363+
assert "layer_id" in response_data["features"][0]["properties"]
364+
assert "preview_url" in response_data["metadata"]
365+
366+
367+
def test_create_vector_layer_from_geojson_with_linestring(
368+
client: TestClient, db: Session, normal_user_access_token: str
369+
) -> None:
370+
current_user = get_current_user(db, normal_user_access_token)
371+
project = create_project(db, owner_id=current_user.id)
372+
vector_layer_data = get_geojson_feature_collection("linestring")
373+
374+
response = client.post(
375+
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/geojson",
376+
json=vector_layer_data,
377+
)
378+
assert response.status_code == status.HTTP_201_CREATED
379+
response_data = response.json()
380+
assert response_data["type"] == "FeatureCollection"
381+
assert len(response_data["features"]) == 1
382+
assert response_data["features"][0]["geometry"]["type"] == "LineString"
383+
assert (
384+
response_data["features"][0]["properties"]["layer_name"] == "Linestring Example"
385+
)
386+
387+
388+
def test_create_vector_layer_from_geojson_with_polygon(
389+
client: TestClient, db: Session, normal_user_access_token: str
390+
) -> None:
391+
current_user = get_current_user(db, normal_user_access_token)
392+
project = create_project(db, owner_id=current_user.id)
393+
vector_layer_data = get_geojson_feature_collection("polygon")
394+
395+
response = client.post(
396+
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/geojson",
397+
json=vector_layer_data,
398+
)
399+
assert response.status_code == status.HTTP_201_CREATED
400+
response_data = response.json()
401+
assert response_data["type"] == "FeatureCollection"
402+
assert len(response_data["features"]) == 1
403+
assert response_data["features"][0]["geometry"]["type"] == "Polygon"
404+
assert response_data["features"][0]["properties"]["layer_name"] == "Polygon Example"
405+
406+
407+
def test_create_vector_layer_from_geojson_with_multiple_features(
408+
client: TestClient, db: Session, normal_user_access_token: str
409+
) -> None:
410+
current_user = get_current_user(db, normal_user_access_token)
411+
project = create_project(db, owner_id=current_user.id)
412+
vector_layer_data = get_geojson_feature_collection("multipoint")
413+
414+
response = client.post(
415+
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/geojson",
416+
json=vector_layer_data,
417+
)
418+
assert response.status_code == status.HTTP_201_CREATED
419+
response_data = response.json()
420+
assert response_data["type"] == "FeatureCollection"
421+
assert len(response_data["features"]) == 3
422+
# All features should have the same layer_id
423+
layer_id = response_data["features"][0]["properties"]["layer_id"]
424+
for feature in response_data["features"]:
425+
assert feature["properties"]["layer_id"] == layer_id
426+
427+
428+
def test_create_vector_layer_from_geojson_with_manager_role(
429+
client: TestClient, db: Session, normal_user_access_token: str
430+
) -> None:
431+
current_user = get_current_user(db, normal_user_access_token)
432+
project = create_project(db)
433+
create_project_member(
434+
db, role=Role.MANAGER, member_id=current_user.id, project_id=project.id
435+
)
436+
vector_layer_data = get_geojson_feature_collection("point")
437+
438+
response = client.post(
439+
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/geojson",
440+
json=vector_layer_data,
441+
)
442+
assert response.status_code == status.HTTP_201_CREATED
443+
444+
445+
def test_create_vector_layer_from_geojson_with_viewer_role(
446+
client: TestClient, db: Session, normal_user_access_token: str
447+
) -> None:
448+
current_user = get_current_user(db, normal_user_access_token)
449+
project = create_project(db)
450+
create_project_member(
451+
db, role=Role.VIEWER, member_id=current_user.id, project_id=project.id
452+
)
453+
vector_layer_data = get_geojson_feature_collection("point")
454+
455+
response = client.post(
456+
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/geojson",
457+
json=vector_layer_data,
458+
)
459+
assert response.status_code == status.HTTP_403_FORBIDDEN
460+
461+
462+
def test_create_vector_layer_from_geojson_without_project_role(
463+
client: TestClient, db: Session, normal_user_access_token: str
464+
) -> None:
465+
project = create_project(db)
466+
vector_layer_data = get_geojson_feature_collection("point")
467+
468+
response = client.post(
469+
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/geojson",
470+
json=vector_layer_data,
471+
)
472+
assert response.status_code == status.HTTP_404_NOT_FOUND
473+
474+
475+
def test_create_vector_layer_from_geojson_with_invalid_longitude(
476+
client: TestClient, db: Session, normal_user_access_token: str
477+
) -> None:
478+
current_user = get_current_user(db, normal_user_access_token)
479+
project = create_project(db, owner_id=current_user.id)
480+
vector_layer_data = get_geojson_feature_collection("invalid_longitude")
481+
482+
response = client.post(
483+
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/geojson",
484+
json=vector_layer_data,
485+
)
486+
assert response.status_code == status.HTTP_400_BAD_REQUEST
487+
assert "longitude" in response.json()["detail"].lower()
488+
489+
490+
def test_create_vector_layer_from_geojson_with_invalid_latitude(
491+
client: TestClient, db: Session, normal_user_access_token: str
492+
) -> None:
493+
current_user = get_current_user(db, normal_user_access_token)
494+
project = create_project(db, owner_id=current_user.id)
495+
vector_layer_data = get_geojson_feature_collection("invalid_latitude")
496+
497+
response = client.post(
498+
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/geojson",
499+
json=vector_layer_data,
500+
)
501+
assert response.status_code == status.HTTP_400_BAD_REQUEST
502+
assert "latitude" in response.json()["detail"].lower()
503+
504+
505+
def test_create_vector_layer_from_geojson_with_empty_features(
506+
client: TestClient, db: Session, normal_user_access_token: str
507+
) -> None:
508+
current_user = get_current_user(db, normal_user_access_token)
509+
project = create_project(db, owner_id=current_user.id)
510+
vector_layer_data = get_geojson_feature_collection("empty_features")
511+
512+
response = client.post(
513+
f"{settings.API_V1_STR}/projects/{project.id}/vector_layers/geojson",
514+
json=vector_layer_data,
515+
)
516+
assert response.status_code == status.HTTP_400_BAD_REQUEST
517+
assert "at least one feature" in response.json()["detail"].lower()

backend/app/tests/utils/utils.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,5 +232,51 @@ def get_geojson_feature_collection(
232232
* 250001,
233233
).model_dump(),
234234
}
235+
elif geom_type.lower() == "invalid_longitude":
236+
return {
237+
"layer_name": "Invalid Longitude Example",
238+
"geojson": FeatureCollection[Feature[Point, Dict[str, object]]](
239+
type="FeatureCollection",
240+
features=[
241+
Feature[Point, Dict[str, object]](
242+
type="Feature",
243+
geometry={
244+
"type": "Point",
245+
"coordinates": [200.0, 0.5], # Invalid longitude
246+
},
247+
properties={
248+
"prop0": "value0",
249+
},
250+
)
251+
],
252+
).model_dump(),
253+
}
254+
elif geom_type.lower() == "invalid_latitude":
255+
return {
256+
"layer_name": "Invalid Latitude Example",
257+
"geojson": FeatureCollection[Feature[Point, Dict[str, object]]](
258+
type="FeatureCollection",
259+
features=[
260+
Feature[Point, Dict[str, object]](
261+
type="Feature",
262+
geometry={
263+
"type": "Point",
264+
"coordinates": [102.0, 95.0], # Invalid latitude
265+
},
266+
properties={
267+
"prop0": "value0",
268+
},
269+
)
270+
],
271+
).model_dump(),
272+
}
273+
elif geom_type.lower() == "empty_features":
274+
return {
275+
"layer_name": "Empty Features Example",
276+
"geojson": FeatureCollection[Feature[Point, Dict[str, object]]](
277+
type="FeatureCollection",
278+
features=[],
279+
).model_dump(),
280+
}
235281
else:
236282
raise ValueError(f"Unknown geometry type provided: {geom_type}")

0 commit comments

Comments
 (0)