From 447a0bec87fc91ef7fc0bf8a4ee7925b38857dba Mon Sep 17 00:00:00 2001 From: urec56 Date: Sat, 23 Mar 2024 14:09:12 +0300 Subject: [PATCH] Initial commit --- .dockerignore | 7 ++++ .env_template | 9 +++++ .gitignore | 6 ++++ DOCS.md | 25 ++++++++++++++ Dockerfile | 13 +++++++ LICENSE | 21 ++++++++++++ README.md | 78 ++++++++++++++++++++++++++++++++++++++++++ app/config.py | 19 +++++++++++ app/exceptions.py | 24 +++++++++++++ app/main.py | 82 +++++++++++++++++++++++++++++++++++++++++++++ app/shemas.py | 5 +++ app/tasks/celery.py | 12 +++++++ app/tasks/tasks.py | 25 ++++++++++++++ docker-compose.yml | 26 ++++++++++++++ requirements.txt | 55 ++++++++++++++++++++++++++++++ test.py | 20 +++++++++++ 16 files changed, 427 insertions(+) create mode 100644 .dockerignore create mode 100644 .env_template create mode 100644 .gitignore create mode 100644 DOCS.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/config.py create mode 100644 app/exceptions.py create mode 100644 app/main.py create mode 100644 app/shemas.py create mode 100644 app/tasks/celery.py create mode 100644 app/tasks/tasks.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 test.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..69a8d09 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.venv +env +.idea +.git +.gitignore +__pycache__ +image.png \ No newline at end of file diff --git a/.env_template b/.env_template new file mode 100644 index 0000000..53a2a92 --- /dev/null +++ b/.env_template @@ -0,0 +1,9 @@ +aws_access_key_id= +aws_secret_access_key= + +bucket_name= +s3_endpoint_url= +region_name= + +REDIS_HOST= +REDIS_PORT= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e153615 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv +venv +.idea +.env +__pycache__ +image.png \ No newline at end of file diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..023586a --- /dev/null +++ b/DOCS.md @@ -0,0 +1,25 @@ +Эндпоинт ожидает запрос с методом `POST`, так как это загрузка файла. + +При попытке подать не изображение `pillow` вызывает исключение `UnidentifiedImageError`, +но если подать `gif`, то исключения не будет. Задача обрабатывать только `png` и `jpeg`, +так что в двух случаях выше возвращается `HTTP_415_UNSUPPORTED_MEDIA_TYPE`. + +Если же подать битый `png` или `jpeg` файл, то `Image.resize` вызовет `OSError` или `SyntaxError`, +так что они отлавливаются отдельно. В таком случае возвращается `HTTP_422_UNPROCESSABLE_ENTITY`. + +Если будет какое-то другое исключение в этом блоке, то вернётся `500` ответ, так как, +по моим тестам, никакого другого исключения быть не должно. В этом блоке стоит добавить логирование, если оно имеется. + +Саму обработку изображений я делаю через `Celery`, так как это позволит не давать лишнюю `CPU Bound` +нагрузку на этот поток и работать в асинхронном режиме. + +Далее генерируется уникальный `UUID` для каждого изображения. +Использование `UUID` позволяет не беспокоиться о том, что будет перезаписано старое изображение. + +Далее генерируется `POST` запрос через `boto3`, отправляется готовое изображение на S3 бакет, +генерируется ссылка на него и возвращается в виде `JSON` `{"resized_image_link": response}`. + +Если в этом блоке будет вызвано исключение, то ошибка никак не связанна с поданными данными, +соответственно не стоит давать лишнюю информацию по ней. +Возвращается `HTTP_500_INTERNAL_SERVER_ERROR` без описания ошибки + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ea39e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11 + +RUN mkdir /image_uploader + +WORKDIR /image_uploader + +COPY requirements.txt . + +RUN pip install -r requirements.txt + +COPY . . + +CMD ["gunicorn", "app.main:app", "--workers", "1", "--worker-class", "uvicorn.workers.UvicornWorker", "--bind=0.0.0.0:8000"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be7c1e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 urec56 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3931cc --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +## Сервис обработки изображений + +Данный сервис принимает на вход изображения типа `png` или `jpeg` и необходимую ширину выходного +изображения в пикселях(Опционально. По умолчанию 250 пикселей), +изменяет размер изображения до указанной ширины сохраняя пропорции, +загружает итоговое изображение на сервер AWS S3 и возвращает ссылку на загруженный файл. + + +## Установка + +Перед началом использования стоит убедиться, что у вас имеется готовый +`AWS S3` бакет. + +Так же для работы `Celery` потребуется сервер `Redis`. +Он может быть поднят отдельно, либо сразу со всем сервисом в докере. + +В корне проекта создаём `.env` файл и заполняем в нём необходимые поля по примеру из `.env_template` + +Далее устанавливаем всё необходимое. +```shell +pip install -r requirements.txt + +---> 100% +``` + +В терминале пишем +```shell +celery -A app.tasks.celery:celery worker --loglevel=INFO +``` + +В другом терминале пишем +```shell +uvicorn app.main:app --reload +``` + +Теперь `API` доступен на http://localhost:8000/api/upload_image +и документация на http://localhost:8000/docs + + +## Установка в docker + +Так же создаём `.env` файл и заполняем его всем необходимым + +В поле `REDIS_HOST` указываем `redis` + +В терминале пишем +```shell +docker compose build +docker compose up -d +``` + +`API` будет доступен на порте `9000` + + +## Пример использования + +```python +import requests + + +def send_image(image_path: str, target_width: int) -> None: + with open(image_path, 'rb') as file: + file_data = file.read() + + url = f'http://localhost:9000/api/upload_image/?target_width={target_width}' + + response = requests.post(url, files={'file': file_data}) + + if response.status_code == 200: + link = response.json()["resized_image_link"] + print("Файл успешно отправлен!", link, sep="\n") + else: + print("Ошибка при отправке файла:", response.status_code, response.json()["detail"]) + + +if __name__ == "__main__": + send_image("image.png", 300) +``` diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..14ad9a0 --- /dev/null +++ b/app/config.py @@ -0,0 +1,19 @@ +from pydantic import ConfigDict +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + model_config = ConfigDict(env_file=".env") + + aws_access_key_id: str + aws_secret_access_key: str + + bucket_name: str + s3_endpoint_url: str + region_name: str + + REDIS_HOST: str + REDIS_PORT: int + + +settings = Settings() diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..53304e9 --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,24 @@ +from fastapi import HTTPException, status + + +class S3Exception(HTTPException): + status_code = 500 + detail = "" + + def __init__(self): + super().__init__(status_code=self.status_code, detail=self.detail) + + +class IncorrectFileException(S3Exception): + status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE + detail = "Неверный тип файла" + + +class BrokenFileException(S3Exception): + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + detail = "Битый файл" + + +class UnexpectedErrorException(S3Exception): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + detail = "Произошла непредвиденная ошибка" diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..841af3a --- /dev/null +++ b/app/main.py @@ -0,0 +1,82 @@ +import uuid + +from fastapi import FastAPI, UploadFile +from fastapi.middleware.cors import CORSMiddleware +import boto3 +from httpx import AsyncClient +from PIL import Image, UnidentifiedImageError +from io import BytesIO + +from app.config import settings +from app.shemas import SImage +from app.tasks.tasks import resize_image +from app.exceptions import IncorrectFileException, BrokenFileException, UnexpectedErrorException + +app = FastAPI( + title="Сервер загрузки изображений в S3", + root_path="/api", +) + +origins = [] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + allow_headers=[ + "Content-Type", + "Set-Cookie", + "Access-Control-Allow-Headers", + "Authorization", + "Accept" + ], +) + +s3 = boto3.client('s3', + endpoint_url=settings.s3_endpoint_url, + region_name=settings.region_name, + aws_access_key_id=settings.aws_access_key_id, + aws_secret_access_key=settings.aws_secret_access_key) + + +@app.post("/upload_image", response_model=SImage) +async def upload_image(file: UploadFile, target_width: int = 250): + + try: + content = await file.read() + + image = Image.open(BytesIO(content)) + image_type = image.format.lower() + if image_type not in ["png", "jpeg"]: + raise IncorrectFileException + + result = resize_image.delay({"file": content}, target_width) + + result_data = result.get() + new_image = result_data["new_image"] + + except UnidentifiedImageError: + raise IncorrectFileException + except (OSError, SyntaxError): + raise BrokenFileException + except Exception: + raise UnexpectedErrorException + + object_key = str(uuid.uuid4()) + f".{image_type}" + + try: + post = s3.generate_presigned_post(settings.bucket_name, object_key, + Fields={"Content-Type": "image/webp"}, + Conditions=[["eq", "$content-type", "image/webp"]]) + async with AsyncClient() as ac: + await ac.post(post["url"], data=post["fields"], files=[("file", (object_key, new_image))]) + + response = s3.generate_presigned_url('get_object', + Params={'Bucket': settings.bucket_name, + 'Key': object_key}, + ExpiresIn=36000) + + return {"resized_image_link": response} + except Exception: + raise UnexpectedErrorException diff --git a/app/shemas.py b/app/shemas.py new file mode 100644 index 0000000..0666e83 --- /dev/null +++ b/app/shemas.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class SImage(BaseModel): + resized_image_link: str diff --git a/app/tasks/celery.py b/app/tasks/celery.py new file mode 100644 index 0000000..a743035 --- /dev/null +++ b/app/tasks/celery.py @@ -0,0 +1,12 @@ +from celery import Celery + + +from app.config import settings + +celery = Celery( + "tasks", + broker=f'redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}', + include=['app.tasks.tasks'] +) + +celery.conf.update(result_backend=f'redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}') diff --git a/app/tasks/tasks.py b/app/tasks/tasks.py new file mode 100644 index 0000000..4b47e74 --- /dev/null +++ b/app/tasks/tasks.py @@ -0,0 +1,25 @@ +from PIL import Image +from io import BytesIO + +from app.tasks.celery import celery + + +@celery.task +def resize_image( + image: dict[str, bytes], + target_width: int, +) -> dict[str, bytes]: + image = image["file"] + + image = Image.open(BytesIO(image)) + + width_percent = (target_width / float(image.size[0])) + height_size = int((float(image.size[1]) * float(width_percent))) + + resized_image = image.resize((target_width, height_size)) + + resized_image_byte_array = BytesIO() + resized_image.save(resized_image_byte_array, format=image.format) + new_image = resized_image_byte_array.getvalue() + + return {"new_image": new_image} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf91f3d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' +services: + redis: + image: redis:7 + container_name: image_uploader_redis + + image_uploader: + image: image_uploader + build: + context: . + container_name: image_uploader + depends_on: + - image_uploader_celery + ports: + - "9000:8000" + + image_uploader_celery: + image: image_uploader_celery + build: + context: . + container_name: image_uploader_celery + depends_on: + - redis + command: ["sh", "-c", "celery -A app.tasks.celery:celery worker --loglevel=INFO"] + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3410b92 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,55 @@ +amqp==5.2.0 +annotated-types==0.6.0 +anyio==4.3.0 +billiard==4.2.0 +boto3==1.34.65 +botocore==1.34.65 +celery==5.3.6 +certifi==2024.2.2 +charset-normalizer==3.3.2 +click==8.1.7 +click-didyoumean==0.3.0 +click-plugins==1.1.1 +click-repl==0.3.0 +dnspython==2.6.1 +email_validator==2.1.1 +fastapi==0.110.0 +gunicorn==21.2.0 +h11==0.14.0 +httpcore==1.0.4 +httptools==0.6.1 +httpx==0.27.0 +idna==3.6 +itsdangerous==2.1.2 +Jinja2==3.1.3 +jmespath==1.0.1 +kombu==5.3.5 +MarkupSafe==2.1.5 +orjson==3.9.15 +packaging==24.0 +pillow==10.2.0 +prompt-toolkit==3.0.43 +pydantic==2.6.4 +pydantic-extra-types==2.6.0 +pydantic-settings==2.2.1 +pydantic_core==2.16.3 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +python-multipart==0.0.9 +PyYAML==6.0.1 +redis==5.0.3 +requests==2.31.0 +s3transfer==0.10.1 +six==1.16.0 +sniffio==1.3.1 +starlette==0.36.3 +typing_extensions==4.10.0 +tzdata==2024.1 +ujson==5.9.0 +urllib3==2.2.1 +uvicorn==0.28.1 +uvloop==0.19.0 +vine==5.1.0 +watchfiles==0.21.0 +wcwidth==0.2.13 +websockets==12.0 diff --git a/test.py b/test.py new file mode 100644 index 0000000..d48db07 --- /dev/null +++ b/test.py @@ -0,0 +1,20 @@ +import requests + + +def send_image(image_path: str, target_width: int) -> None: + with open(image_path, 'rb') as file: + file_data = file.read() + + url = f'http://localhost:8000/api/upload_image/?target_width={target_width}' + + response = requests.post(url, files={'file': file_data}) + + if response.status_code == 200: + link = response.json()["resized_image_link"] + print("Файл успешно отправлен!", link, sep="\n") + else: + print("Ошибка при отправке файла:", response.status_code, response.json()["detail"]) + + +if __name__ == "__main__": + send_image("image.png", 300)