Skip to content

Commit 269891e

Browse files
authored
Merge pull request #12 from swissgeo/GPS-898-feat-add-delete-endpoint
GPS-898: add delete drawing feature
2 parents 9887117 + 59ff7e0 commit 269891e

9 files changed

Lines changed: 270 additions & 76 deletions

File tree

app/api/wps.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Annotated
1010

1111
from fastapi import APIRouter, File, Form, Request, UploadFile
12-
from fastapi.responses import StreamingResponse
12+
from fastapi.responses import Response, StreamingResponse
1313

1414
from app.core.drawings import DrawingsService, DrawingsServiceDep
1515
from app.schemas.drawings import DrawingsCreateResponse, DrawingsUpdateResponse
@@ -38,6 +38,14 @@
3838
File(description="The KMZ file to upload. Only KMZ files are accepted."),
3939
]
4040

41+
AdminIdForm = Annotated[
42+
uuid.UUID,
43+
Form(
44+
description="Admin identifier required to authorize the operation",
45+
examples=["00000000-0000-0000-0000-000000000000"],
46+
),
47+
]
48+
4149

4250
@router.post(
4351
"/drawings",
@@ -104,13 +112,7 @@ async def get_drawing(
104112
async def update_drawing( # noqa: PLR0913, PLR0917
105113
request: Request,
106114
drawing_id: uuid.UUID,
107-
admin_id: Annotated[
108-
uuid.UUID,
109-
Form(
110-
description="Admin identifier required to update the drawing",
111-
examples=["00000000-0000-0000-0000-000000000000"],
112-
),
113-
],
115+
admin_id: AdminIdForm,
114116
file: KmzFile,
115117
sha256: Sha256Form,
116118
drawings: DrawingsServiceDep,
@@ -124,3 +126,26 @@ async def update_drawing( # noqa: PLR0913, PLR0917
124126
timestamps.
125127
"""
126128
return await drawings.update_drawing(drawing_id, admin_id, file, request, sha256)
129+
130+
131+
@router.delete(
132+
"/drawings/{drawing_id}",
133+
status_code=204,
134+
responses={
135+
403: {"model": ErrorResponse},
136+
404: {"model": ErrorResponse},
137+
500: {"model": ErrorResponse},
138+
},
139+
)
140+
async def delete_drawing(
141+
drawing_id: uuid.UUID,
142+
admin_id: AdminIdForm,
143+
drawings: DrawingsServiceDep,
144+
) -> Response:
145+
"""Delete a KMZ drawing.
146+
147+
The admin_id must match the stored drawing metadata, otherwise the request
148+
is rejected with 403. The deletion is permanent.
149+
"""
150+
await drawings.delete_drawing(drawing_id, admin_id)
151+
return Response(status_code=204)

app/core/drawings.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,37 @@ async def get_drawing(self, drawing_id: uuid.UUID) -> tuple[AsyncIterator[bytes]
272272

273273
return self._s3.get_drawing(s3_key), s3_key
274274

275+
async def delete_drawing(self, drawing_id: uuid.UUID, admin_id: uuid.UUID) -> None:
276+
"""Delete a KMZ drawing from S3.
277+
278+
Verifies the drawing exists and the admin_id matches the stored metadata
279+
before deleting the object. The deletion is permanent, the only way to
280+
recover it is through S3 versioning.
281+
282+
Args:
283+
drawing_id: The UUID of the drawing to delete.
284+
admin_id: Admin identifier that must match the stored drawing.
285+
286+
Raises:
287+
DrawingNotFoundError: If no drawing exists with the given identifier.
288+
AdminIdMismatchError: If the admin_id does not match the stored one.
289+
S3Error: If the S3 delete operation fails.
290+
291+
"""
292+
s3_key = self.build_s3_key(drawing_id)
293+
294+
# Head the object first so a missing drawing surfaces as a 404 before
295+
# the delete is attempted
296+
existing = await self._s3.head_drawing(s3_key)
297+
298+
if existing.get("admin-id") != str(admin_id):
299+
logger.warning("admin_id mismatch for drawing %s", drawing_id)
300+
raise AdminIdMismatchError
301+
302+
await self._s3.delete_drawing(s3_key)
303+
304+
logger.info("Drawing deleted: id=%s", drawing_id)
305+
275306

276307
async def get_drawings_service(
277308
s3: S3ServiceDep,

app/core/s3.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ async def head_drawing(self, key: str) -> dict[str, str]:
117117
else:
118118
return metadata
119119

120+
async def delete_drawing(self, key: str) -> None:
121+
"""Delete a KMZ file from S3.
122+
123+
Args:
124+
key: S3 object key
125+
126+
Raises:
127+
S3Error: If the S3 delete fails
128+
129+
"""
130+
try:
131+
await self._client.delete_object(Bucket=self._bucket, Key=key)
132+
except botocore.exceptions.BotoCoreError as e:
133+
logger.exception("S3 delete failed for key %s", key)
134+
raise S3Error(f"S3 delete failed for key {key}: {e}") from e
135+
120136
async def check_bucket(self) -> bool:
121137
"""Check whether the configured S3 bucket is accessible.
122138

app/tests/test_drawings_endpoints.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,3 +311,72 @@ def test_create_drawing_body_size_within_limit(client: TestClient, valid_kmz_byt
311311
data={"sha256": _sha256(valid_kmz_bytes)},
312312
)
313313
assert response.status_code == 201
314+
315+
316+
def test_delete_drawing_success(client: TestClient, valid_kmz_bytes: bytes):
317+
"""DELETE an existing drawing with the correct admin_id returns 204 and removes it."""
318+
create_resp = client.post(
319+
"/api/wps/v1/drawings",
320+
files={"file": ("test.kmz", valid_kmz_bytes, "application/vnd.google-earth.kmz")},
321+
data={"sha256": _sha256(valid_kmz_bytes)},
322+
)
323+
assert create_resp.status_code == 201
324+
drawing_id = create_resp.json()["id"]
325+
admin_id = create_resp.json()["admin_id"]
326+
327+
response = client.request(
328+
"DELETE",
329+
f"/api/wps/v1/drawings/{drawing_id}",
330+
data={"admin_id": admin_id},
331+
)
332+
assert response.status_code == 204
333+
334+
# The drawing must no longer be retrievable
335+
get_resp = client.get(f"/api/wps/v1/drawings/{drawing_id}")
336+
assert get_resp.status_code == 404
337+
338+
339+
def test_delete_drawing_wrong_admin_id(client: TestClient, valid_kmz_bytes: bytes):
340+
"""DELETE with a mismatched admin_id returns 403 Forbidden."""
341+
create_resp = client.post(
342+
"/api/wps/v1/drawings",
343+
files={"file": ("test.kmz", valid_kmz_bytes, "application/vnd.google-earth.kmz")},
344+
data={"sha256": _sha256(valid_kmz_bytes)},
345+
)
346+
assert create_resp.status_code == 201
347+
drawing_id = create_resp.json()["id"]
348+
349+
response = client.request(
350+
"DELETE",
351+
f"/api/wps/v1/drawings/{drawing_id}",
352+
data={"admin_id": str(uuid.uuid4())},
353+
)
354+
assert response.status_code == 403
355+
assert "detail" in response.json()
356+
357+
358+
def test_delete_drawing_not_found(client: TestClient):
359+
"""DELETE a non-existent drawing returns 404 Not Found."""
360+
response = client.request(
361+
"DELETE",
362+
"/api/wps/v1/drawings/00000000-0000-0000-0000-000000000000",
363+
data={"admin_id": str(uuid.uuid4())},
364+
)
365+
assert response.status_code == 404
366+
assert "detail" in response.json()
367+
368+
369+
def test_delete_drawing_missing_admin_id(client: TestClient):
370+
"""DELETE without the admin_id form field returns 422 Unprocessable Entity."""
371+
response = client.request("DELETE", "/api/wps/v1/drawings/00000000-0000-0000-0000-000000000000")
372+
assert response.status_code == 422
373+
374+
375+
def test_delete_drawing_invalid_uuid(client: TestClient):
376+
"""DELETE with a malformed UUID returns 422 Unprocessable Entity."""
377+
response = client.request(
378+
"DELETE",
379+
"/api/wps/v1/drawings/not-a-valid-uuid",
380+
data={"admin_id": str(uuid.uuid4())},
381+
)
382+
assert response.status_code == 422

app/tests/test_s3_service.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,39 @@ async def test_check_bucket_failure(settings) -> None:
213213
svc = S3Service(client=client, bucket=settings.aws_s3_bucket_name)
214214

215215
assert await svc.check_bucket() is False
216+
217+
218+
@pytest.mark.asyncio
219+
async def test_delete_drawing_success(settings) -> None:
220+
"""Upload then delete, verify the object is gone."""
221+
session = aioboto3.Session()
222+
async with session.client("s3", endpoint_url=settings.aws_endpoint_url) as client: # type: ignore # noqa: PGH003
223+
svc = S3Service(client=client, bucket=settings.aws_s3_bucket_name)
224+
key = "drawings/delete-test.kmz"
225+
data = b"content to delete"
226+
metadata = {
227+
"sha256": "jkl012",
228+
"admin-id": "11111111-1111-1111-1111-111111111111",
229+
"created-at": "2026-01-01T00:00:00+00:00",
230+
"modified-at": "2026-01-01T00:00:00+00:00",
231+
}
232+
233+
await svc.upload_drawing(
234+
key, io.BytesIO(data), "application/vnd.google-earth.kmz", metadata
235+
)
236+
237+
await svc.delete_drawing(key)
238+
239+
with pytest.raises(DrawingNotFoundError):
240+
await svc.head_drawing(key)
241+
242+
243+
@pytest.mark.asyncio
244+
async def test_delete_drawing_botocore_error(settings) -> None:
245+
"""A BotoCoreError during delete should raise S3Error."""
246+
client = MagicMock()
247+
client.delete_object = AsyncMock(side_effect=botocore.exceptions.BotoCoreError())
248+
svc = S3Service(client=client, bucket=settings.aws_s3_bucket_name)
249+
250+
with pytest.raises(S3Error):
251+
await svc.delete_drawing("drawings/x.kmz")

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ dependencies = [
1414
# https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation#readme
1515
"httptools~=0.8.0",
1616
"opentelemetry-exporter-otlp~=1.41",
17-
"opentelemetry-instrumentation-asgi==0.62b1", # asgi is used by fastapi
18-
"opentelemetry-instrumentation-botocore==0.62b1",
19-
"opentelemetry-instrumentation-fastapi==0.62b1",
17+
"opentelemetry-instrumentation-asgi==0.65b0", # asgi is used by fastapi
18+
"opentelemetry-instrumentation-botocore==0.65b0",
19+
"opentelemetry-instrumentation-fastapi==0.65b0",
2020
"opentelemetry-sdk~=1.41",
2121
"orjson~=3.11",
2222
"pydantic-settings~=2.13",

scripts/smoke_test_drawings_api.sh

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,32 @@ else
325325
fail " Downloaded content does NOT match updated file"
326326
fi
327327

328+
# --- DELETE /api/wps/v1/drawings/{id} ---
329+
echo -e "\n${BOLD}=== DELETE /api/wps/v1/drawings/{id} ===${NC}"
330+
331+
HTTP=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \
332+
-F "admin_id=$WRONG_ADMIN_ID" \
333+
"$BASE_URL/api/wps/v1/drawings/$DRAWING_ID")
334+
assert_status "Reject wrong admin_id" 403 "$HTTP"
335+
336+
HTTP=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \
337+
-F "admin_id=$ADMIN_ID" \
338+
"$BASE_URL/api/wps/v1/drawings/00000000-0000-0000-0000-000000000000")
339+
assert_status "Delete non-existent drawing → 404" 404 "$HTTP"
340+
341+
HTTP=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \
342+
"$BASE_URL/api/wps/v1/drawings/$DRAWING_ID")
343+
assert_status "Delete without admin_id form field → 422" 422 "$HTTP"
344+
345+
HTTP=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \
346+
-F "admin_id=$ADMIN_ID" \
347+
"$BASE_URL/api/wps/v1/drawings/$DRAWING_ID")
348+
assert_status "Delete existing drawing → 204" 204 "$HTTP"
349+
350+
HTTP=$(curl -s -o /dev/null -w '%{http_code}' \
351+
"$BASE_URL/api/wps/v1/drawings/$DRAWING_ID")
352+
assert_status "Deleted drawing no longer retrievable → 404" 404 "$HTTP"
353+
328354
# --- OpenAPI spec ---
329355
echo -e "\n${BOLD}=== OpenAPI Spec ===${NC}"
330356

scripts/test_s3_local.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
TEST_FILE = "France.kmz"
3030

3131

32-
async def main() -> None:
32+
async def main() -> None: # noqa: PLR0915
3333
import aioboto3 # noqa: PLC0415
3434

3535
session = aioboto3.Session()
@@ -94,6 +94,19 @@ async def main() -> None:
9494
except DrawingNotFoundError:
9595
print("OK (DrawingNotFoundError)")
9696

97+
# Delete
98+
print("6. delete_drawing ...", end=" ", flush=True)
99+
await svc.delete_drawing(key)
100+
print("OK")
101+
102+
# Head after delete (should be gone)
103+
print("7. head_drawing (after delete) ...", end=" ", flush=True)
104+
try:
105+
await svc.head_drawing(key)
106+
print("FAIL (expected DrawingNotFoundError)")
107+
except DrawingNotFoundError:
108+
print("OK (DrawingNotFoundError)")
109+
97110
print()
98111
print("All checks passed ✓")
99112

0 commit comments

Comments
 (0)