Initial commit

This commit is contained in:
urec56 2024-03-23 14:09:12 +03:00
commit 447a0bec87
16 changed files with 427 additions and 0 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
.venv
env
.idea
.git
.gitignore
__pycache__
image.png

9
.env_template Normal file
View file

@ -0,0 +1,9 @@
aws_access_key_id=
aws_secret_access_key=
bucket_name=
s3_endpoint_url=
region_name=
REDIS_HOST=
REDIS_PORT=

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.venv
venv
.idea
.env
__pycache__
image.png

25
DOCS.md Normal file
View file

@ -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` без описания ошибки

13
Dockerfile Normal file
View file

@ -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"]

21
LICENSE Normal file
View file

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

78
README.md Normal file
View file

@ -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)
```

19
app/config.py Normal file
View file

@ -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()

24
app/exceptions.py Normal file
View file

@ -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 = "Произошла непредвиденная ошибка"

82
app/main.py Normal file
View file

@ -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

5
app/shemas.py Normal file
View file

@ -0,0 +1,5 @@
from pydantic import BaseModel
class SImage(BaseModel):
resized_image_link: str

12
app/tasks/celery.py Normal file
View file

@ -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}')

25
app/tasks/tasks.py Normal file
View file

@ -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}

26
docker-compose.yml Normal file
View file

@ -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"]

55
requirements.txt Normal file
View file

@ -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

20
test.py Normal file
View file

@ -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)