Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
21 changes: 3 additions & 18 deletions fiftyone/server/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,23 @@
|
"""

from json import JSONEncoder
import traceback
import typing as t
import logging

from bson import json_util
import numpy as np
from starlette.endpoints import HTTPEndpoint
from starlette.exceptions import HTTPException
from starlette.responses import JSONResponse, Response
from starlette.requests import Request

from fiftyone.core.utils import run_sync_task


class Encoder(JSONEncoder):
"""Custom JSON encoder that handles numpy types."""

def default(self, o):
if isinstance(o, np.floating):
return float(o)

if isinstance(o, np.integer):
return int(o)

return JSONEncoder.default(self, o)
from fiftyone.server import utils


async def create_response(response: dict):
"""Creates a JSON response from the given dictionary."""
return Response(
await run_sync_task(lambda: json_util.dumps(response, cls=Encoder)),
await run_sync_task(lambda: utils.json.dumps(response)),
headers={"Content-Type": "application/json"},
)

Expand All @@ -52,7 +37,7 @@ async def wrapper(
try:
body = await request.body()
payload = body.decode("utf-8")
data = json_util.loads(payload) if payload else {}
data = utils.json.loads(payload)
response = await func(endpoint, request, data, *args)
if isinstance(response, Response):
return response
Expand Down
66 changes: 51 additions & 15 deletions fiftyone/server/routes/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,40 @@
|
"""

import datetime
import logging
import hashlib
from typing import Any, List

from starlette.endpoints import HTTPEndpoint
from starlette.exceptions import HTTPException
from starlette.requests import Request

import fiftyone as fo
import fiftyone.core.odm.utils as fou
from typing import List
from fiftyone.server.utils.jsonpatch import parse
from fiftyone.server.utils import transform_json

from fiftyone.server import utils
from fiftyone.server.decorators import route
from typing import Any

logger = logging.getLogger(__name__)


def get_sample(dataset_id: str, sample_id: str) -> fo.Sample:
def get_sample(request: Request) -> fo.Sample:
"""Retrieves a sample from a dataset.

Args:
dataset_id: the dataset ID
sample_id: the sample ID
request: The request object containing dataset ID and sample ID

Returns:
the sample

Raises:
HTTPException: if the dataset or sample is not found
"""

dataset_id = request.path_params["dataset_id"]
sample_id = request.path_params["sample_id"]

try:
dataset = fou.load_dataset(id=dataset_id)
except ValueError:
Expand All @@ -52,13 +56,29 @@ def get_sample(dataset_id: str, sample_id: str) -> fo.Sample:
detail=f"Sample '{sample_id}' not found in dataset '{dataset_id}'",
)

if request.headers.get("If-Match"):
etag, _ = utils.http.ETag.parse(request.headers["If-Match"])

if etag == str(generate_sample_etag(sample)):
return sample

if etag == sample.last_modified_at.isoformat():
return sample

if etag == str(sample.last_modified_at.timestamp()):
return sample

raise HTTPException(status_code=412, detail="ETag does not match")

return sample


def handle_json_patch(target: Any, patch_list: List[dict]) -> Any:
"""Applies a list of JSON patch operations to a target object."""
try:
patches = parse(patch_list, transform_fn=transform_json)
patches = utils.json.parse_jsonpatch(
patch_list, transform_fn=utils.json.deserialize
)
except Exception as e:
raise HTTPException(
status_code=400,
Expand All @@ -81,6 +101,14 @@ def handle_json_patch(target: Any, patch_list: List[dict]) -> Any:
return target


def generate_sample_etag(sample: fo.Sample) -> str:
"""Generates an ETag for a sample."""
# pylint:disable-next=protected-access
content = f"{sample.last_modified_at.isoformat()}"
hex_digest = hashlib.md5(content.encode("utf-8")).hexdigest()
return int(hex_digest, 16)


class Sample(HTTPEndpoint):
@route
async def patch(self, request: Request, data: dict) -> dict:
Expand All @@ -103,8 +131,7 @@ async def patch(self, request: Request, data: dict) -> dict:
sample_id,
dataset_id,
)

sample = get_sample(dataset_id, sample_id)
sample = get_sample(request)

content_type = request.headers.get("Content-Type", "")
ctype = content_type.split(";", 1)[0].strip().lower()
Expand All @@ -117,8 +144,13 @@ async def patch(self, request: Request, data: dict) -> dict:
status_code=415,
detail=f"Unsupported Content-Type '{ctype}'",
)
sample.save()
return result.to_dict(include_private=True)

result.save()

return utils.json.JSONResponse(
utils.json.serialize(result),
headers={"ETag": f'"{generate_sample_etag(result)}"'},
)

def _handle_patch(self, sample: fo.Sample, data: dict) -> dict:
errors = {}
Expand All @@ -128,7 +160,7 @@ def _handle_patch(self, sample: fo.Sample, data: dict) -> dict:
sample.clear_field(field_name)
continue

sample[field_name] = transform_json(value)
sample[field_name] = utils.json.deserialize(value)
except Exception as e:
errors[field_name] = str(e)

Expand Down Expand Up @@ -167,7 +199,7 @@ async def patch(self, request: Request, data: dict) -> dict:
dataset_id,
)

sample = get_sample(dataset_id, sample_id)
sample = get_sample(request)

try:
field_list = sample.get_field(path)
Expand All @@ -192,7 +224,11 @@ async def patch(self, request: Request, data: dict) -> dict:

result = handle_json_patch(field, data)
sample.save()
return result.to_dict()

return utils.json.JSONResponse(
utils.json.serialize(result),
headers={"ETag": f'"{generate_sample_etag(sample)}"'},
)


SampleRoutes = [
Expand Down
5 changes: 2 additions & 3 deletions fiftyone/server/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@

import fiftyone.core.dataset as fod
import fiftyone.core.fields as fof
from fiftyone.server.utils.json_transform import (
transform as transform_json,
) # auto-register resource types
from fiftyone.server.utils import json
from fiftyone.server.utils import http


_cache = cachetools.TTLCache(maxsize=10, ttl=900) # ttl in seconds
Expand Down
33 changes: 33 additions & 0 deletions fiftyone/server/utils/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""
HTTP utils
| Copyright 2017-2025, Voxel51, Inc.
| `voxel51.com <https://voxel51.com/>`_
|
"""

from typing import Any


class ETag:
"""Utility class for creating and parsing ETag strings."""

@staticmethod
def create(value: Any) -> str:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this used anywhere? I see in the sample method "generate_sample_etage" you're doing this same thing

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope. It should be, I'll add it in.

"""Creates an ETag string from the given value."""
return f'"{value}"'

@staticmethod
def parse(etag: str) -> tuple[str, bool]:
"""Parses an ETag string into its value and whether it is weak."""

is_weak = False
if etag.startswith("W/"):
is_weak = True
etag = etag[2:] # Remove "W/" prefix

# Remove surrounding quotes (ETags are typically quoted)
if etag.startswith('"') and etag.endswith('"'):
etag = etag[1:-1]

return etag, is_weak
32 changes: 32 additions & 0 deletions fiftyone/server/utils/json/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""

| Copyright 2017-2025, Voxel51, Inc.
| `voxel51.com <https://voxel51.com/>`_
|
"""

from typing import Any, Union
from bson import json_util

from starlette.responses import JSONResponse as StarletteJSONResponse

from fiftyone.server.utils.json.encoder import Encoder
from fiftyone.server.utils.json.jsonpatch import parse as parse_jsonpatch
from fiftyone.server.utils.json.serialization import deserialize, serialize


def dumps(obj: Any) -> str:
"""Serializes an object to a JSON-formatted string."""
return json_util.dumps(obj, cls=Encoder)


def loads(s: Union[str, bytes, bytearray, None]) -> Any:
"""Deserializes a JSON-formatted string to a Python object."""
return json_util.loads(s) if s else {}


class JSONResponse(StarletteJSONResponse):
"""Custom JSON response that uses the custom Encoder."""

def render(self, content: Any) -> bytes:
return dumps(content).encode("utf-8")
45 changes: 45 additions & 0 deletions fiftyone/server/utils/json/encoder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""

| Copyright 2017-2025, Voxel51, Inc.
| `voxel51.com <https://voxel51.com/>`_
|
"""

from typing import Any, Union
import json

from bson import json_util
import numpy as np
from starlette.responses import JSONResponse as StarletteJSONResponse


class Encoder(json.JSONEncoder):
"""Custom JSON encoder that handles numpy types."""

def default(self, o):
"""Override the default method to handle numpy types."""

if isinstance(o, np.floating):
return float(o)

if isinstance(o, np.integer):
return int(o)

return json.JSONEncoder.default(self, o)


def dumps(obj: Any) -> str:
"""Serializes an object to a JSON-formatted string."""
return json_util.dumps(obj, cls=Encoder)


def loads(s: Union[str, bytes, bytearray, None]) -> Any:
"""Deserializes a JSON-formatted string to a Python object."""
return json_util.loads(s) if s else {}


class JSONResponse(StarletteJSONResponse):
"""Custom JSON response that uses the custom Encoder."""

def render(self, content: Any) -> bytes:
return dumps(content).encode("utf-8")
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@

from typing import Any, Callable, Iterable, Optional, Union

from fiftyone.server.utils.jsonpatch.methods import (

from fiftyone.server.utils.json.jsonpatch.methods import (
add,
copy,
move,
remove,
replace,
test,
)
from fiftyone.server.utils.jsonpatch.patch import (

from fiftyone.server.utils.json.jsonpatch.patch import (
Patch,
Operation,
Add,
Expand All @@ -27,6 +29,7 @@
Test,
)


__PATCH_MAP = {
Operation.ADD: Add,
Operation.COPY: Copy,
Expand Down
Loading
Loading