Skip to content
Open
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
39 changes: 36 additions & 3 deletions api/hastefuncapi/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@
from hastegeo.core.utils.data import convert_json_to_geojson, filter_roles
from hastegeo.core.utils.logs import Logger
from hastegeo.core.utils.metadata import MetadataUtils
from hastegeo.core.utils.request_fields import (
IMAGE_LAYER_SERVER_MANAGED_FIELDS,
INFERENCE_SERVER_MANAGED_FIELDS,
changed_server_managed_fields,
server_managed_fields_message,
)
from hastegeo.core.utils.source_types import normalize_source_type
from hastegeo.core.utils.url_allowlist import (
validate_clip_bbox,
Expand Down Expand Up @@ -1029,6 +1035,13 @@ async def PutLayer(req: func.HttpRequest) -> func.HttpResponse:

try:
req_body = req.get_json()
supplied_server_fields = changed_server_managed_fields(
req_body, IMAGE_LAYER_SERVER_MANAGED_FIELDS
)
if not req_body.get("imageLayerId") and supplied_server_fields:
return _bad_request(
server_managed_fields_message(supplied_server_fields)
)
image_data = ImageLayer(**req_body)

url_error = validate_image_layer_imagery_urls(image_data)
Expand Down Expand Up @@ -1061,8 +1074,21 @@ async def PutLayer(req: func.HttpRequest) -> func.HttpResponse:
except FileNotFoundError:
existing_image_layer = None

changed_server_fields = changed_server_managed_fields(
req_body,
IMAGE_LAYER_SERVER_MANAGED_FIELDS,
existing=existing_image_layer,
)
if changed_server_fields:
return _bad_request(
server_managed_fields_message(changed_server_fields)
)

if existing_image_layer:
# This is an edit
for field in IMAGE_LAYER_SERVER_MANAGED_FIELDS:
if field in existing_image_layer:
setattr(image_data, field, existing_image_layer[field])
output = image_data
else:
output = await asyncio.to_thread(
Expand Down Expand Up @@ -1470,9 +1496,9 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse:
# interactive labeler's other artifacts are fetched by range and parsed
# in-browser, so they must NOT be forced as downloads).
if kind == "gpkg":
headers[
"Content-Disposition"
] = f'attachment; filename="building_predictions_{model_id}.gpkg"'
headers["Content-Disposition"] = "; ".join(
["attachment", f'filename="building_predictions_{model_id}.gpkg"']
)
if result.etag:
headers["ETag"] = (
result.etag if result.etag.startswith('"') else f'"{result.etag}"'
Expand Down Expand Up @@ -2458,6 +2484,13 @@ async def PutRunInferenceQueueMessage(
)
try:
req_body = req.get_json()
changed_server_fields = changed_server_managed_fields(
req_body, INFERENCE_SERVER_MANAGED_FIELDS
)
if changed_server_fields:
return _bad_request(
server_managed_fields_message(changed_server_fields)
)
output = Model(**req_body)
if output.creationDate is None:
output.creationDate = MetadataUtils.get_timestamp()
Expand Down
79 changes: 79 additions & 0 deletions api/hastefuncapi/tests/test_request_field_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
import io
import json
import os
import unittest
from contextlib import redirect_stderr
from unittest.mock import Mock, patch

import azure.functions as func

os.environ.setdefault("DEVELOPMENT_MODE", "true")
os.environ.setdefault("METADATA_STORAGE_TYPE", "local")
os.environ.setdefault("ARTIFACT_STORAGE_TYPE", "local")
os.environ.setdefault("DATA_PATH", "/tmp/haste-request-field-api-tests")
os.environ.setdefault("TEMP_DATA_PATH", "/tmp/haste-request-field-api-tests")

with redirect_stderr(io.StringIO()):
from api.hastefuncapi import function_app

PROJECT_ID = "123e4567-e89b-12d3-a456-426614174000"
LAYER_ID = "550e8400-e29b-41d4-a716-446655440000"


def make_layer_request(status: str | None) -> func.HttpRequest:
body = {
"projectId": PROJECT_ID,
"imageLayerId": LAYER_ID,
"name": "Renamed layer",
}
if status is not None:
body["status"] = status
return func.HttpRequest(
method="PUT",
url="http://localhost/api/PutLayer",
headers={},
params={},
route_params={},
body=json.dumps(body).encode(),
)


class TestPutLayerServerManagedFields(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.processor = Mock()
self.processor.load.return_value = {
"projectId": PROJECT_ID,
"imageLayerId": LAYER_ID,
"name": "Original layer",
"status": "Processed",
}
patcher = patch.object(
function_app, "MetadataProcessor", return_value=self.processor
)
self.addCleanup(patcher.stop)
patcher.start()

async def test_edit_accepts_unchanged_server_managed_values(self) -> None:
response = await function_app.PutLayer(make_layer_request("Processed"))

self.assertEqual(response.status_code, 200)
self.processor.save.assert_called_once()

async def test_edit_rejects_changed_server_managed_values(self) -> None:
response = await function_app.PutLayer(make_layer_request("Failed"))

self.assertEqual(response.status_code, 400)
self.processor.save.assert_not_called()

async def test_partial_edit_preserves_omitted_server_state(self) -> None:
response = await function_app.PutLayer(make_layer_request(None))

self.assertEqual(response.status_code, 200)
saved = self.processor.save.call_args.args[1]
self.assertEqual(saved["status"], "Processed")


if __name__ == "__main__":
unittest.main()
73 changes: 73 additions & 0 deletions hastelib/src/hastegeo/core/utils/request_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
"""Request-boundary rules for persisted workflow state."""

from collections.abc import Collection, Mapping
from typing import Any

IMAGE_LAYER_SERVER_MANAGED_FIELDS = frozenset(
{
"buildingFootprintsUrl",
"creationDate",
"currentStep",
"imageryPath",
"labelProject",
"labelProjectCount",
"labelsUrl",
"modelCount",
"models",
"normalizationMeans",
"normalizationStds",
"postEventMosaicCogImageryUrl",
"postEventPreviewUrls",
"postEventProcessedImageryUrl",
"preEventMosaicCogImageryUrl",
"preEventPreviewUrls",
"preEventProcessedImageryUrl",
"preprocessJob",
"previewSourceImageryUrls",
"processedImageryUrls",
"progressPct",
"rawImageryUrls",
"status",
"statusMessage",
"totalSteps",
"validAreaMaskUrl",
}
)

INFERENCE_SERVER_MANAGED_FIELDS = frozenset(
{
"currentInferenceTaskId",
"gpkgUrl",
"inferenceCurrentStep",
"inferenceJobs",
"inferenceProgressPct",
"inferenceStatus",
"inferenceStatusMessage",
"inferenceTotalSteps",
"inferenceUid",
"predictedDamageLayerUrl",
}
)


def changed_server_managed_fields(
payload: Mapping[str, Any],
protected_fields: Collection[str],
existing: Mapping[str, Any] | None = None,
) -> list[str]:
"""Return protected fields supplied for create or changed during update."""
supplied = set(payload).intersection(protected_fields)
if existing is None:
return sorted(supplied)
return sorted(
field for field in supplied if payload[field] != existing.get(field)
)


def server_managed_fields_message(fields: Collection[str]) -> str:
"""Build a stable boundary-validation message."""
return "Server-managed fields cannot be supplied or changed: " + ", ".join(
sorted(fields)
)
46 changes: 46 additions & 0 deletions hastelib/tests/core/utils/test_request_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
import unittest

from hastegeo.core.utils.request_fields import (
changed_server_managed_fields,
server_managed_fields_message,
)


class TestServerManagedFields(unittest.TestCase):
def test_create_rejects_supplied_protected_fields(self) -> None:
result = changed_server_managed_fields(
{"name": "Layer", "status": "Processed"},
{"status", "progressPct"},
)

self.assertEqual(result, ["status"])

def test_update_allows_unchanged_protected_fields(self) -> None:
result = changed_server_managed_fields(
{"name": "Renamed", "status": "Processed"},
{"status"},
existing={"name": "Original", "status": "Processed"},
)

self.assertEqual(result, [])

def test_update_rejects_changed_and_new_protected_fields(self) -> None:
result = changed_server_managed_fields(
{"status": "Processed", "progressPct": 100},
{"status", "progressPct"},
existing={"status": "Queued"},
)

self.assertEqual(result, ["progressPct", "status"])

def test_message_is_stable_and_sorted(self) -> None:
self.assertEqual(
server_managed_fields_message(["status", "gpkgUrl"]),
"Server-managed fields cannot be supplied or changed: gpkgUrl, status",
)


if __name__ == "__main__":
unittest.main()
Loading