Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/develop' into add-update-endpoin…
Browse files Browse the repository at this point in the history
…t-without-primary-for-images-#35
  • Loading branch information
asuresh-code committed Dec 5, 2024
2 parents 3a4fa4e + 0477879 commit f4feb13
Show file tree
Hide file tree
Showing 11 changed files with 383 additions and 64 deletions.
20 changes: 5 additions & 15 deletions object_storage_api/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@


class BaseAPIException(Exception):
"""
Base exception for API errors.
"""
"""Base exception for API errors."""

# Status code to return if this exception is raised
status_code: int
Expand All @@ -39,15 +37,11 @@ def __init__(self, detail: str, response_detail: Optional[str] = None):


class DatabaseError(BaseAPIException):
"""
Database related error.
"""
"""Database related error."""


class InvalidObjectIdError(DatabaseError):
"""
The provided value is not a valid ObjectId.
"""
"""The provided value is not a valid ObjectId."""

status_code = 404
response_detail = "Invalid ID given"
Expand All @@ -68,18 +62,14 @@ def __init__(self, detail: str, response_detail: Optional[str] = None, entity_na


class InvalidImageFileError(BaseAPIException):
"""
The provided image file is not valid.
"""
"""The provided image file is not valid."""

status_code = 422
response_detail = "File given is not a valid image"


class MissingRecordError(DatabaseError):
"""
A specific database record was requested but could not be found.
"""
"""A specific database record was requested but could not be found."""

status_code = 404
response_detail = "Requested record was not found"
Expand Down
19 changes: 13 additions & 6 deletions object_storage_api/repositories/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from object_storage_api.core.custom_object_id import CustomObjectId
from object_storage_api.core.database import DatabaseDep
from object_storage_api.core.exceptions import InvalidObjectIdError
from object_storage_api.core.exceptions import InvalidObjectIdError, MissingRecordError
from object_storage_api.models.image import ImageIn, ImageOut

logger = logging.getLogger()
Expand Down Expand Up @@ -43,20 +43,27 @@ def create(self, image: ImageIn, session: ClientSession = None) -> ImageOut:
result = self._images_collection.insert_one(image.model_dump(by_alias=True), session=session)
return self.get(str(result.inserted_id), session=session)

def get(self, image_id: str, session: ClientSession = None) -> Optional[ImageOut]:
def get(self, image_id: str, session: ClientSession = None) -> ImageOut:
"""
Retrieve an image by its ID from a MongoDB database.
:param image_id: ID of the image to retrieve.
:param session: PyMongo ClientSession to use for database operations.
:return: Retrieved image or `None` if not found.
:return: Retrieved image if found.
:raises MissingRecordError: If the supplied `image_id` is non-existent.
:raises InvalidObjectIdError: If the supplied `image_id` is invalid.
"""
image_id = CustomObjectId(image_id)
logger.info("Retrieving image with ID: %s from the database", image_id)
image = self._images_collection.find_one({"_id": image_id}, session=session)
try:
image_id = CustomObjectId(image_id)
image = self._images_collection.find_one({"_id": image_id}, session=session)
except InvalidObjectIdError as exc:
exc.status_code = 404
exc.response_detail = "Image not found"
raise exc
if image:
return ImageOut(**image)
return None
raise MissingRecordError(detail=f"No image found with ID: {image_id}", entity_name="image")

def list(self, entity_id: Optional[str], primary: Optional[bool], session: ClientSession = None) -> list[ImageOut]:
"""
Expand Down
30 changes: 25 additions & 5 deletions object_storage_api/routers/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
import logging
from typing import Annotated, Optional

from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, Path, Query, UploadFile, status

from object_storage_api.schemas.image import ImagePatchMetadataSchema, ImagePostMetadataSchema, ImageSchema
from object_storage_api.schemas.image import (
ImageMetadataSchema,
ImagePatchMetadataSchema,
ImagePostMetadataSchema,
ImageSchema,
)
from object_storage_api.services.image import ImageService

logger = logging.getLogger()
Expand Down Expand Up @@ -36,7 +41,7 @@ def create_image(
upload_file: Annotated[UploadFile, File(description="Image file")],
title: Annotated[Optional[str], Form(description="Title of the image")] = None,
description: Annotated[Optional[str], Form(description="Description of the image")] = None,
) -> ImageSchema:
) -> ImageMetadataSchema:
# pylint: disable=missing-function-docstring
logger.info("Creating a new image")

Expand All @@ -57,7 +62,7 @@ def get_images(
image_service: ImageServiceDep,
entity_id: Annotated[Optional[str], Query(description="Filter images by entity ID")] = None,
primary: Annotated[Optional[bool], Query(description="Filter images by primary")] = None,
) -> list[ImageSchema]:
) -> list[ImageMetadataSchema]:
# pylint: disable=missing-function-docstring
logger.info("Getting images")

Expand All @@ -69,12 +74,27 @@ def get_images(
return image_service.list(entity_id, primary)


@router.get(path="/{image_id}", summary="Get an image by ID", response_description="Single image")
def get_image(
image_id: Annotated[str, Path(description="ID of the image to get")],
image_service: ImageServiceDep,
) -> ImageSchema:
# pylint: disable=missing-function-docstring
logger.info("Getting image with ID: %s", image_id)

return image_service.get(image_id)


@router.patch(
path="/{image_id}",
summary="Update image",
response_description="Updated Image",
)
def partial_update_image(image_service: ImageServiceDep, image_id: str, image: ImagePatchMetadataSchema) -> ImageSchema:
def partial_update_image(
image_id: Annotated[str, Path(description="ID of the image to update")],
image: ImagePatchMetadataSchema,
image_service: ImageServiceDep,
) -> ImageMetadataSchema:
logger.info("Updating images")

return image_service.update(image_id, image)
18 changes: 10 additions & 8 deletions object_storage_api/schemas/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from typing import Optional

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, HttpUrl

from object_storage_api.schemas.mixins import CreatedModifiedSchemaMixin

Expand All @@ -19,19 +19,21 @@ class ImagePatchMetadataSchema(BaseModel):


class ImagePostMetadataSchema(ImagePatchMetadataSchema):
"""
Base schema model for an image.
"""
"""Base schema model for an image."""

entity_id: str = Field(description="ID of the entity the image relates to")


class ImageSchema(CreatedModifiedSchemaMixin, ImagePostMetadataSchema):
"""
Schema model for an image get request response.
"""
class ImageMetadataSchema(CreatedModifiedSchemaMixin, ImagePostMetadataSchema):
"""Schema model for an image's metadata."""

id: str = Field(description="ID of the image")
file_name: str = Field(description="File name of the image")
primary: bool = Field(description="Whether the image is the primary for its related entity")
thumbnail_base64: str = Field(description="Thumbnail of the image as a base64 encoded byte string")


class ImageSchema(ImageMetadataSchema):
"""Schema model for an image get request response."""

url: HttpUrl = Field(description="Presigned get URL to get the image file")
30 changes: 23 additions & 7 deletions object_storage_api/services/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
from object_storage_api.core.image import generate_thumbnail_base64_str
from object_storage_api.models.image import ImageIn
from object_storage_api.repositories.image import ImageRepo
from object_storage_api.schemas.image import ImagePatchMetadataSchema, ImagePostMetadataSchema, ImageSchema
from object_storage_api.schemas.image import (
ImageMetadataSchema,
ImagePatchMetadataSchema,
ImagePostMetadataSchema,
ImageSchema,
)
from object_storage_api.stores.image import ImageStore

logger = logging.getLogger()
Expand All @@ -38,7 +43,7 @@ def __init__(
self._image_repository = image_repository
self._image_store = image_store

def create(self, image_metadata: ImagePostMetadataSchema, upload_file: UploadFile) -> ImageSchema:
def create(self, image_metadata: ImagePostMetadataSchema, upload_file: UploadFile) -> ImageMetadataSchema:
"""
Create a new image.
Expand Down Expand Up @@ -73,9 +78,20 @@ def create(self, image_metadata: ImagePostMetadataSchema, upload_file: UploadFil

image_out = self._image_repository.create(image_in)

return ImageSchema(**image_out.model_dump())
return ImageMetadataSchema(**image_out.model_dump())

def list(self, entity_id: Optional[str] = None, primary: Optional[bool] = None) -> list[ImageSchema]:
def get(self, image_id: str) -> ImageSchema:
"""
Retrieve an image's metadata with its presigned get url by its ID.
:param image_id: ID of the image to retrieve.
:return: An image's metadata with a presigned get url.
"""
image = self._image_repository.get(image_id=image_id)
presigned_url = self._image_store.create_presigned_get(image)
return ImageSchema(**image.model_dump(), url=presigned_url)

def list(self, entity_id: Optional[str] = None, primary: Optional[bool] = None) -> list[ImageMetadataSchema]:
"""
Retrieve a list of images based on the provided filters.
Expand All @@ -84,9 +100,9 @@ def list(self, entity_id: Optional[str] = None, primary: Optional[bool] = None)
:return: List of images or an empty list if no images are retrieved.
"""
images = self._image_repository.list(entity_id, primary)
return [ImageSchema(**image.model_dump()) for image in images]
return [ImageMetadataSchema(**image.model_dump()) for image in images]

def update(self, image_id: str, image: ImagePatchMetadataSchema) -> ImageSchema:
def update(self, image_id: str, image: ImagePatchMetadataSchema) -> ImageMetadataSchema:
"""
Update an image based on its ID.
Expand All @@ -102,4 +118,4 @@ def update(self, image_id: str, image: ImagePatchMetadataSchema) -> ImageSchema:
image_id=image_id, image=ImageIn(**{**stored_image.model_dump(), **update_data})
)

return ImageSchema(**updated_image.model_dump())
return ImageMetadataSchema(**updated_image.model_dump())
21 changes: 21 additions & 0 deletions object_storage_api/stores/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from fastapi import UploadFile

from object_storage_api.core.object_store import object_storage_config, s3_client
from object_storage_api.models.image import ImageOut
from object_storage_api.schemas.image import ImagePostMetadataSchema

logger = logging.getLogger()
Expand Down Expand Up @@ -37,3 +38,23 @@ def upload(self, image_id: str, image_metadata: ImagePostMetadataSchema, upload_
)

return object_key

def create_presigned_get(self, image: ImageOut) -> str:
"""
Generate a presigned URL to share an S3 object.
:param image: `ImageOut` model of the image.
:return: Presigned url to get the image.
"""
logger.info("Generating presigned url to get image from object storage")
response = s3_client.generate_presigned_url(
"get_object",
Params={
"Bucket": object_storage_config.bucket_name.get_secret_value(),
"Key": image.object_key,
"ResponseContentDisposition": f'inline; filename="{image.file_name}"',
},
ExpiresIn=object_storage_config.presigned_url_expiry_seconds,
)

return response
Loading

0 comments on commit f4feb13

Please sign in to comment.