Skip to content

Commit

Permalink
Merge pull request #69 from ral-facilities/add-get-endpoint-for-image…
Browse files Browse the repository at this point in the history
…-metadata-with-presigned-url-#37

Add Get Endpoint for Image metadata and presigned url
  • Loading branch information
asuresh-code authored Dec 5, 2024
2 parents df779f0 + 43bea90 commit 0477879
Show file tree
Hide file tree
Showing 11 changed files with 394 additions and 58 deletions.
46 changes: 33 additions & 13 deletions object_storage_api/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
# TODO: Some of this file is identical to the one in inventory-management-system-api - Use common repo?


from typing import Optional


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 @@ -19,37 +20,56 @@ class BaseAPIException(Exception):

detail: str

def __init__(self, detail: str):
def __init__(self, detail: str, response_detail: Optional[str] = None):
"""
Initialise the exception.
:param detail: Specific detail of the exception (just like Exception would take - this will only be logged
and not returned in a response).
:param response_detail: Generic detail of the exception that will be returned in a response.
"""
super().__init__(detail)

self.detail = detail

if response_detail is not None:
self.response_detail = response_detail


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 = 422
response_detail = "Invalid ID given"


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."""

status_code = 404
response_detail = "Requested record was not found"

def __init__(self, detail: str, response_detail: Optional[str] = None, entity_name: Optional[str] = None):
"""
Initialise the exception.
:param detail: Specific detail of the exception (just like Exception would take - this will only be logged
and not returned in a response).
:param response_detail: Generic detail of the exception to be returned in the response.
:param entity_name: Name of the entity to include in the response detail.
"""
super().__init__(detail, response_detail)

if entity_name is not None:
self.response_detail = f"{entity_name.capitalize()} not found"
18 changes: 13 additions & 5 deletions object_storage_api/repositories/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +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, MissingRecordError
from object_storage_api.models.image import ImageIn, ImageOut

logger = logging.getLogger()
Expand Down Expand Up @@ -42,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
19 changes: 15 additions & 4 deletions object_storage_api/routers/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
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 ImagePostMetadataSchema, ImageSchema
from object_storage_api.schemas.image import ImageMetadataSchema, ImagePostMetadataSchema, ImageSchema
from object_storage_api.services.image import ImageService

logger = logging.getLogger()
Expand Down Expand Up @@ -36,7 +36,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 +57,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 @@ -67,3 +67,14 @@ def get_images(
logger.debug("Primary filter: '%s'", primary)

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)
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,27 +4,29 @@

from typing import Optional

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

from object_storage_api.schemas.mixins import CreatedModifiedSchemaMixin


class ImagePostMetadataSchema(BaseModel):
"""
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")
title: Optional[str] = Field(default=None, description="Title of the image")
description: Optional[str] = Field(default=None, description="Description of the image")


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")
21 changes: 16 additions & 5 deletions object_storage_api/services/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
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 ImagePostMetadataSchema, ImageSchema
from object_storage_api.schemas.image import ImageMetadataSchema, ImagePostMetadataSchema, ImageSchema
from object_storage_api.stores.image import ImageStore

logger = logging.getLogger()
Expand All @@ -38,7 +38,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 +73,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,4 +95,4 @@ 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]
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 0477879

Please sign in to comment.