Skip to content

Commit 7370c39

Browse files
author
Nikita Kolybelkin
committed
File storage mvp
0 parents  commit 7370c39

25 files changed

+1348
-0
lines changed

.dockerignore

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
*
2+
!file_storage/
3+
!scripts/
4+
!run.py
5+
!poetry.lock
6+
!pyproject.toml
7+
!tests/
8+
!yoyo.ini

.env

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
DB_NAME=file_storage
2+
DB_USER=postgres
3+
DB_PASSWORD=postgres
4+
DB_SERVER=postgres

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/venv/
2+
/.idea
3+
/.pytest_cache

Dockerfile

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
FROM python:3.7.4-alpine3.10 as builder
2+
3+
ARG DEVELOPMENT_BUILD=true
4+
ENV PIP_NO_CACHE_DIR=1 \
5+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
6+
PYTHONIOENCODING=utf-8 \
7+
POETRY_VIRTUALENVS_CREATE="false"
8+
9+
WORKDIR /src
10+
11+
RUN apk add --no-cache gcc make musl-dev libffi-dev libressl-dev git
12+
13+
RUN pip install -U pip==20.2.3 \
14+
&& pip install poetry==1.0.10
15+
16+
ADD pyproject.toml poetry.lock /src/
17+
RUN poetry export \
18+
--format requirements.txt \
19+
--output requirements.txt \
20+
--without-hashes \
21+
--with-credentials \
22+
$(test "$DEVELOPMENT_BUILD" = "true" && echo "--dev") \
23+
&& apk add --no-cache postgresql-dev \
24+
&& mkdir /wheels \
25+
&& pip wheel -r requirements.txt --wheel-dir /wheels \
26+
&& rm requirements.txt
27+
28+
FROM python:3.7.4-alpine3.10
29+
30+
ENV PIP_NO_CACHE_DIR=1 \
31+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
32+
PIP_NO_INDEX=1 \
33+
PYTHONDONTWRITEBYTECODE=1 \
34+
LANG=C.UTF-8 \
35+
LC_ALL=C.UTF-8
36+
37+
WORKDIR /src
38+
39+
COPY --from=builder /wheels /wheels
40+
RUN apk add --no-cache libpq libssl1.1 \
41+
&& pip install /wheels/*
42+
43+
COPY . .
44+
45+
ENTRYPOINT ["/bin/sh"]

Readme.MD

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# File storage
2+
3+
### Запуск
4+
`scripts/run.sh`
5+
6+
## Описание
7+
Тестовый проект, позволяет загружать файлы, в ответ получать уникальные ссылки.
8+
Обрабатывается сценарий загрузки одинаковых файлов, в данном случае создаём только новый линк. (см. хранимку в миграциях)

docker-compose.yml

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
version: '3.8'
2+
services:
3+
file_storage:
4+
depends_on:
5+
- postgres
6+
image: $COMMIT_HASH
7+
networks:
8+
- file_storage
9+
env_file:
10+
- .env
11+
ports:
12+
- 8080:8080
13+
command: ["/src/scripts/start_server.sh"]
14+
15+
postgres:
16+
image: postgres:11
17+
networks:
18+
- file_storage
19+
environment:
20+
- POSTGRES_DB=file_storage
21+
- POSTGRES_USER=postgres
22+
- POSTGRES_PASSWORD=postgres
23+
24+
ports:
25+
- 5432:5432
26+
27+
networks:
28+
file_storage:

file_storage/app.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import typing as tp
2+
3+
from aiohttp import web
4+
from file_storage.router import setup_api_routes
5+
from file_storage.setup import on_shutdown, on_startup
6+
from file_storage.typings import AioHttpApplication
7+
8+
9+
def get_application() -> AioHttpApplication:
10+
"""Get app."""
11+
app: AioHttpApplication = tp.cast(AioHttpApplication, web.Application())
12+
setup_api_routes(app)
13+
app.on_startup.extend(on_startup)
14+
app.on_shutdown.extend(on_shutdown)
15+
return app

file_storage/conf.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from pydantic import BaseSettings
2+
3+
4+
class Settings(BaseSettings):
5+
"""App settings."""
6+
7+
DB_NAME: str = "file_storage"
8+
DB_USER: str = "postgres"
9+
DB_PASSWORD: str = "postgres"
10+
DB_SERVER = "localhost"
11+
DB_PORT = 5432
12+
13+
TEST_DB_NAME: str = f"test_{DB_NAME}"
14+
15+
@property
16+
def postgres_uri(self) -> str:
17+
"""Postgres uri string."""
18+
return "postgres://{username}:{password}@{address}:{port}/{db_name}".format(
19+
username=self.DB_USER,
20+
password=self.DB_PASSWORD,
21+
address=self.DB_SERVER,
22+
port=self.DB_PORT,
23+
db_name=self.DB_NAME,
24+
)
25+
26+
27+
settings = Settings()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
3+
"""
4+
5+
from yoyo import step
6+
7+
__depends__ = {}
8+
9+
steps = [
10+
step(
11+
"""
12+
CREATE TABLE IF NOT EXISTS "uploaded_files" (
13+
"id" BIGSERIAL PRIMARY KEY,
14+
"data" bytea NOT NULL,
15+
"hash" varchar(32) NOT NULL
16+
);
17+
COMMENT ON TABLE "uploaded_files" IS 'Mapping hash to uploaded file.';
18+
CREATE UNIQUE INDEX uploaded_files_hash_idx ON uploaded_files (hash);
19+
CREATE TABLE IF NOT EXISTS "files_meta" (
20+
"id" BIGSERIAL PRIMARY KEY,
21+
"uuid" uuid NOT NULL UNIQUE,
22+
"content_type" TEXT NOT NULL,
23+
"hash" varchar(32),
24+
FOREIGN KEY (hash) REFERENCES uploaded_files (hash)
25+
);
26+
COMMENT ON TABLE "files_meta" IS 'Mapping uuid to name and hash of uploaded file.';
27+
CREATE UNIQUE INDEX files_meta_uuid ON files_meta (uuid);
28+
29+
-- Database procedure for update file to database and get hash
30+
CREATE OR REPLACE FUNCTION upload_file(data bytea) RETURNS varchar(32) as $$
31+
DECLARE computed_hash varchar(32);
32+
BEGIN
33+
computed_hash = md5(data);
34+
INSERT INTO uploaded_files (data, hash) VALUES (data::bytea, computed_hash)
35+
ON CONFLICT (hash) DO NOTHING;
36+
RETURN computed_hash;
37+
END;
38+
$$ LANGUAGE plpgsql;
39+
""",
40+
"""
41+
DROP TABLE IF EXISTS "files_meta" CASCADE;
42+
DROP INDEX IF EXISTS "files_meta_uuid";
43+
DROP TABLE IF EXISTS "uploaded_files" CASCADE;
44+
DROP INDEX IF EXISTS "uploaded_files_hash_idx";
45+
DROP FUNCTION IF EXISTS "upload_file";
46+
""",
47+
)
48+
]

file_storage/router.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from file_storage.typings import AioHttpApplication
2+
from file_storage.views import FileStorageHandler
3+
4+
5+
def setup_api_routes(app: AioHttpApplication) -> None:
6+
"""Setup landing API routes."""
7+
app.router.add_put("/api/v1/file_storage", FileStorageHandler.put)
8+
app.router.add_get(r"/api/v1/file_storage/{uuid}", FileStorageHandler.get)

file_storage/setup.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import asyncpg
2+
from file_storage.conf import settings
3+
from file_storage.typings import AioHttpApplication
4+
5+
6+
async def get_db() -> asyncpg.pool.Pool:
7+
"""
8+
Creates postgres pool.
9+
10+
We need separate function for testing purposes.
11+
"""
12+
return await asyncpg.create_pool(
13+
database=settings.DB_NAME,
14+
user=settings.DB_USER,
15+
password=settings.DB_PASSWORD,
16+
host=settings.DB_SERVER,
17+
port=settings.DB_PORT,
18+
statement_cache_size=0,
19+
)
20+
21+
22+
async def setup_app_postgres(app: AioHttpApplication) -> None:
23+
"""Creates postgres pool for app."""
24+
app.db = await get_db()
25+
26+
27+
async def shutdown_postgres(app: AioHttpApplication):
28+
"""Close postgres."""
29+
await app.db.close()
30+
31+
32+
on_startup = (setup_app_postgres,)
33+
on_shutdown = (shutdown_postgres,)

file_storage/sql.py

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
def upload_data_query():
2+
"""
3+
Insert new data and return hash.
4+
5+
On case when hash already exists.
6+
"""
7+
return """SELECT hash FROM upload_file($1) hash;"""
8+
9+
10+
def insert_meta_query():
11+
"""Save meta data for uploaded file."""
12+
return """
13+
INSERT INTO files_meta (uuid, content_type, hash) VALUES ($1, $2, $3);
14+
"""
15+
16+
17+
def retrieve_data_by_uuid():
18+
"""Retrieve file by uuid."""
19+
return """
20+
SELECT
21+
uf.data,
22+
files_meta.content_type
23+
FROM files_meta
24+
JOIN uploaded_files uf ON uf.hash = files_meta.hash
25+
WHERE uuid = $1;
26+
"""

file_storage/typings.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from aiohttp import web
2+
from asyncpg.pool import Pool
3+
4+
5+
class AioHttpApplication(web.Application):
6+
"""Type hints for application with additional attributes."""
7+
8+
db: Pool

file_storage/views.py

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import logging
2+
from typing import cast
3+
from uuid import uuid4
4+
5+
from aiohttp import web
6+
from asyncpg import Connection
7+
from file_storage.sql import (
8+
insert_meta_query,
9+
retrieve_data_by_uuid,
10+
upload_data_query,
11+
)
12+
from file_storage.typings import AioHttpApplication
13+
from pydantic import UUID4
14+
15+
16+
class FileStorageHandler:
17+
logger = logging.getLogger("FileStorageHandler")
18+
19+
@classmethod
20+
async def put(cls, request: web.Request):
21+
"""Upload action handler."""
22+
app: AioHttpApplication = cast(AioHttpApplication, request.app)
23+
insert_data_query = upload_data_query()
24+
payload = await request.read()
25+
conn: Connection
26+
async with app.db.acquire() as conn:
27+
async with conn.transaction():
28+
uploaded_file = await conn.fetchrow(insert_data_query, payload)
29+
file_hash = uploaded_file["hash"]
30+
file_uuid = str(uuid4())
31+
cls.logger.info(
32+
f"File hash: {file_hash}, file_uuid: {file_uuid}"
33+
)
34+
await conn.execute(
35+
insert_meta_query(),
36+
file_uuid,
37+
request.content_type,
38+
file_hash,
39+
)
40+
return web.json_response(
41+
{"link": str(request.rel_url / file_uuid)},
42+
status=web.HTTPCreated.status_code,
43+
)
44+
45+
@classmethod
46+
async def get(cls, request: web.Request):
47+
"""Get file from storage by UUID."""
48+
try:
49+
uuid = UUID4(request.match_info["uuid"])
50+
except ValueError:
51+
raise web.HTTPBadRequest
52+
app: AioHttpApplication = cast(AioHttpApplication, request.app)
53+
conn: Connection
54+
query = retrieve_data_by_uuid()
55+
async with app.db.acquire() as conn:
56+
data = await conn.fetchrow(query, uuid)
57+
if not data:
58+
raise web.HTTPNotFound
59+
return web.Response(
60+
body=data["data"], content_type=data["content_type"]
61+
)

0 commit comments

Comments
 (0)