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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ class MyCustomStore(ObjectStore):
self,
bucket_name: BucketName,
*,
prefix: Key | None = None,
prefix: str | None = None,
delimiter: str | None = None,
max_keys: MaxKeys = 1000,
marker: Key | None = None,
marker: str | None = None,
encoding_type: str | None = None,
) -> ListObjectsInfo: ...
async def list_objects_v2(
self,
Expand All @@ -91,7 +92,7 @@ class MyCustomStore(ObjectStore):
delimiter: str | None = None,
encoding_type: str | None = None,
max_keys: MaxKeys = 1000,
prefix: Key | None = None,
prefix: str | None = None,
start_after: Key | None = None,
) -> ListObjectsV2Info: ...
```
Expand Down Expand Up @@ -149,3 +150,4 @@ uv run mypy .
## License

Apache 2.0 – see the [LICENSE](./LICENSE) file for details.

7 changes: 4 additions & 3 deletions examples/custom_store.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ class MyCustomStore(ObjectStore):
self,
bucket_name: BucketName,
*,
prefix: Key | None = None,
prefix: str | None = None,
delimiter: str | None = None,
max_keys: MaxKeys = 1000,
marker: Key | None = None,
marker: str | None = None,
encoding_type: str | None = None,
) -> ListObjectsInfo: ...
async def list_objects_v2(
self,
Expand All @@ -38,6 +39,6 @@ class MyCustomStore(ObjectStore):
delimiter: str | None = None,
encoding_type: str | None = None,
max_keys: MaxKeys = 1000,
prefix: Key | None = None,
prefix: str | None = None,
start_after: Key | None = None,
) -> ListObjectsV2Info: ...
22 changes: 15 additions & 7 deletions src/boxdrive/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
from typing import Annotated, Literal

from fastapi import APIRouter, Depends, Header, Query, Request, Response, status
from fastapi import APIRouter, Depends, Header, Query, Request, Response
from fastapi.responses import StreamingResponse

from . import (
Expand Down Expand Up @@ -33,18 +33,26 @@ async def list_buckets(s3: S3Dep) -> XMLResponse:
@router.get("/{bucket}")
async def list_objects(
bucket: BucketName,
prefix: Key | None = Query(None),
prefix: str | None = Query(None),
delimiter: str | None = Query(None),
max_keys: MaxKeys = Query(constants.MAX_KEYS, alias="max-keys"),
marker: Key | None = Query(None),
marker: str | None = Query(None),
continuation_token: Key | None = Query(None, alias="continuation-token"),
start_after: Key | None = Query(None, alias="start-after"),
list_type: Literal["1", "2"] = Query("1", alias="list-type"),
encoding_type: Literal["url"] | None = Query(None, alias="encoding-type"),
*,
s3: S3Dep,
) -> XMLResponse:
if list_type == "1":
objects = await s3.list_objects(bucket, prefix=prefix, delimiter=delimiter, max_keys=max_keys, marker=marker)
objects = await s3.list_objects(
bucket,
prefix=prefix,
delimiter=delimiter,
max_keys=max_keys,
marker=marker,
encoding_type=encoding_type,
)
else:
objects = await s3.list_objects_v2(
bucket,
Expand All @@ -53,6 +61,7 @@ async def list_objects(
max_keys=max_keys,
continuation_token=continuation_token,
start_after=start_after,
encoding_type=encoding_type,
)
return XMLResponse(objects)

Expand Down Expand Up @@ -98,9 +107,8 @@ async def put_object(


@router.delete("/{bucket}/{key:path}")
async def delete_object(bucket: BucketName, key: Key, s3: S3Dep) -> XMLResponse:
await s3.delete_object(bucket, key)
return XMLResponse(status_code=status.HTTP_204_NO_CONTENT)
async def delete_object(bucket: BucketName, key: Key, s3: S3Dep) -> Response:
return await s3.delete_object(bucket, key)


@router.put("/{bucket}")
Expand Down
2 changes: 1 addition & 1 deletion src/boxdrive/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
logger.info(
"Response info: %s",
{
"status_code": status_code,
"method": method,
"path": path,
"status_code": status_code,
"process_time": f"{process_time:.3f}s",
"content_length": content_length,
},
Expand Down
81 changes: 51 additions & 30 deletions src/boxdrive/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@
import os
from collections.abc import AsyncIterator

from fastapi import HTTPException, Response
from fastapi import HTTPException, Response, status
from fastapi.responses import StreamingResponse
from opentelemetry import trace

from boxdrive.schemas import BaseListObjectsInfo

from . import constants, exceptions
from .schemas import BucketName, ContentType, Key, MaxKeys, xml
from .store import ObjectStore

tracer = trace.get_tracer(__name__)
logger = logging.getLogger(__name__)


class S3:
def __init__(self, store: ObjectStore):
self.store = store

@tracer.start_as_current_span("list_buckets")
async def list_buckets(self) -> xml.ListAllMyBucketsResult:
buckets = await self.store.list_buckets()
buckets_xml = [
Expand All @@ -27,57 +30,66 @@ async def list_buckets(self) -> xml.ListAllMyBucketsResult:
buckets_model = xml.Buckets(buckets=buckets_xml)
return xml.ListAllMyBucketsResult(owner=owner, buckets=buckets_model)

async def list_objects_v2(
@tracer.start_as_current_span("list_objects")
async def list_objects(
self,
bucket: BucketName,
prefix: Key | None = None,
prefix: str | None = None,
delimiter: str | None = None,
max_keys: MaxKeys = constants.MAX_KEYS,
continuation_token: Key | None = None,
start_after: Key | None = None,
marker: Key | None = None,
encoding_type: str | None = None,
) -> xml.ListBucketResult:
objects_info = await self.store.list_objects_v2(
bucket,
prefix=prefix,
delimiter=delimiter,
max_keys=max_keys,
continuation_token=continuation_token,
start_after=start_after,
objects_info = await self.store.list_objects(
bucket, prefix=prefix, delimiter=delimiter, max_keys=max_keys, marker=marker, encoding_type=encoding_type
)
return self._build_list_bucket_result(
bucket,
objects_info,
next_marker=objects_info.next_marker,
objects_info=objects_info,
prefix=prefix,
delimiter=delimiter,
max_keys=max_keys,
)

async def list_objects(
@tracer.start_as_current_span("list_objects_v2")
async def list_objects_v2(
self,
bucket: BucketName,
prefix: Key | None = None,
prefix: str | None = None,
delimiter: str | None = None,
max_keys: MaxKeys = constants.MAX_KEYS,
marker: Key | None = None,
continuation_token: Key | None = None,
start_after: Key | None = None,
encoding_type: str | None = None,
) -> xml.ListBucketResult:
objects_info = await self.store.list_objects(
bucket, prefix=prefix, delimiter=delimiter, max_keys=max_keys, marker=marker
objects_info = await self.store.list_objects_v2(
bucket,
prefix=prefix,
delimiter=delimiter,
max_keys=max_keys,
continuation_token=continuation_token,
start_after=start_after,
encoding_type=encoding_type,
)
return self._build_list_bucket_result(
bucket,
objects_info,
objects_info=objects_info,
prefix=prefix,
delimiter=delimiter,
max_keys=max_keys,
)

# TODO: exclude None NextMarker from response
def _build_list_bucket_result(
self,
bucket: BucketName,
*,
objects_info: BaseListObjectsInfo,
prefix: Key | None = None,
prefix: str | None = None,
delimiter: str | None = None,
max_keys: MaxKeys = constants.MAX_KEYS,
next_marker: str = "",
) -> xml.ListBucketResult:
objects: list[xml.Content] = []
for obj in objects_info.objects:
Expand All @@ -99,10 +111,12 @@ def _build_list_bucket_result(
key_count=len(objects) + len(objects_info.common_prefixes),
is_truncated=objects_info.is_truncated,
delimiter=delimiter,
next_marker=next_marker or None,
contents=objects,
common_prefixes=[xml.CommonPrefix(prefix=prefix) for prefix in objects_info.common_prefixes],
)

@tracer.start_as_current_span("get_object")
async def get_object(
self,
bucket: BucketName,
Expand Down Expand Up @@ -153,10 +167,11 @@ async def generate() -> AsyncIterator[bytes]:
status_code=status_code,
)

@tracer.start_as_current_span("head_object")
async def head_object(self, bucket: BucketName, key: Key) -> Response:
metadata = await self.store.head_object(bucket, key)
return Response(
status_code=200,
status_code=status.HTTP_200_OK,
headers={
"Content-Length": str(metadata.size),
"ETag": f'"{metadata.etag}"',
Expand All @@ -166,6 +181,7 @@ async def head_object(self, bucket: BucketName, key: Key) -> Response:
},
)

@tracer.start_as_current_span("put_object")
async def put_object(
self,
bucket: BucketName,
Expand All @@ -175,27 +191,32 @@ async def put_object(
) -> Response:
final_content_type = content_type or constants.DEFAULT_CONTENT_TYPE
result_etag = await self.store.put_object(bucket, key, content, final_content_type)
return Response(status_code=200, headers={"ETag": f'"{result_etag}"', "Content-Length": "0"})
return Response(status_code=status.HTTP_200_OK, headers={"ETag": f'"{result_etag}"'})

async def delete_object(self, bucket: BucketName, key: Key) -> None:
@tracer.start_as_current_span("delete_object")
async def delete_object(self, bucket: BucketName, key: Key) -> Response:
try:
await self.store.delete_object(bucket, key)
except exceptions.NoSuchBucket:
logger.info("Bucket %s not found", bucket)
except exceptions.NoSuchKey:
logger.info("Object %s not found in bucket %s", key, bucket)
return None
return Response(
status_code=status.HTTP_204_NO_CONTENT,
headers={
"content-length": "0",
},
)

@tracer.start_as_current_span("create_bucket")
async def create_bucket(self, bucket: BucketName) -> Response:
try:
await self.store.create_bucket(bucket)
except exceptions.BucketAlreadyExists:
raise HTTPException(status_code=409, detail="Bucket already exists")
return Response(status_code=200, headers={"Location": f"/{bucket}"})
await self.store.create_bucket(bucket)
return Response(status_code=status.HTTP_200_OK, headers={"Location": f"/{bucket}"})

@tracer.start_as_current_span("delete_bucket")
async def delete_bucket(self, bucket: BucketName) -> Response:
try:
await self.store.delete_bucket(bucket)
except exceptions.NoSuchBucket:
logger.info("Bucket %s not found", bucket)
return Response(status_code=204)
return Response(status_code=status.HTTP_204_NO_CONTENT)
2 changes: 1 addition & 1 deletion src/boxdrive/schemas/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class BaseListObjectsInfo(BaseModel):


class ListObjectsInfo(BaseListObjectsInfo):
pass
next_marker: str = ""


class ListObjectsV2Info(BaseListObjectsInfo):
Expand Down
1 change: 1 addition & 0 deletions src/boxdrive/schemas/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,4 @@ class ListBucketResult(BaseXmlModel):
delimiter: str | None = element(tag="Delimiter", default=None)
contents: list[Content] = element(tag="Contents")
common_prefixes: list[CommonPrefix] = element(tag="CommonPrefixes")
next_marker: str | None = element(tag="NextMarker", default=None)
7 changes: 4 additions & 3 deletions src/boxdrive/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ async def list_objects(
self,
bucket_name: BucketName,
*,
prefix: Key | None = None,
prefix: str | None = None,
delimiter: str | None = None,
max_keys: MaxKeys = constants.MAX_KEYS,
marker: Key | None = None,
marker: str | None = None,
encoding_type: str | None = None,
) -> ListObjectsInfo:
"""List objects in a bucket."""
pass
Expand All @@ -56,7 +57,7 @@ async def list_objects_v2(
delimiter: str | None = None,
encoding_type: str | None = None,
max_keys: MaxKeys = constants.MAX_KEYS,
prefix: Key | None = None,
prefix: str | None = None,
start_after: Key | None = None,
) -> ListObjectsV2Info:
"""List objects in a bucket."""
Expand Down
Loading