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