Initial commit
This commit is contained in:
commit
447a0bec87
16 changed files with 427 additions and 0 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
@ -0,0 +1,7 @@
|
|||
.venv
|
||||
env
|
||||
.idea
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__
|
||||
image.png
|
9
.env_template
Normal file
9
.env_template
Normal 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
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
.venv
|
||||
venv
|
||||
.idea
|
||||
.env
|
||||
__pycache__
|
||||
image.png
|
25
DOCS.md
Normal file
25
DOCS.md
Normal 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
13
Dockerfile
Normal 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
21
LICENSE
Normal 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
78
README.md
Normal 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
19
app/config.py
Normal 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
24
app/exceptions.py
Normal 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
82
app/main.py
Normal 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
5
app/shemas.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SImage(BaseModel):
|
||||
resized_image_link: str
|
12
app/tasks/celery.py
Normal file
12
app/tasks/celery.py
Normal 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
25
app/tasks/tasks.py
Normal 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
26
docker-compose.yml
Normal 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
55
requirements.txt
Normal 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
20
test.py
Normal 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)
|
Loading…
Add table
Reference in a new issue