Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions hw3/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Используем официальный Python-образ
FROM python:3.11-slim

# Устанавливаем рабочую директорию
WORKDIR /app

# Устанавливаем зависимости
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Копируем всё приложение
COPY app/ ./app

# Указываем команду запуска (через uvicorn)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Empty file added hw3/app/__init__.py
Empty file.
Empty file added hw3/app/api/__init__.py
Empty file.
9 changes: 9 additions & 0 deletions hw3/app/api/pokemon/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .contracts import PatchPokemonRequest, PokemonRequest, PokemonResponse
from .routes import router

__all__ = [
"PokemonResponse",
"PokemonRequest",
"PatchPokemonRequest",
"router",
]
36 changes: 36 additions & 0 deletions hw3/app/api/pokemon/contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

from pydantic import BaseModel, ConfigDict
from app.store.models import PatchPokemonInfo, PokemonEntity, PokemonInfo


class PokemonResponse(BaseModel):
id: int
name: str
published: bool

@staticmethod
def from_entity(entity: PokemonEntity) -> PokemonResponse:
return PokemonResponse(
id=entity.id,
name=entity.info.name,
published=entity.info.published,
)


class PokemonRequest(BaseModel):
name: str
published: bool

def as_pokemon_info(self) -> PokemonInfo:
return PokemonInfo(name=self.name, published=self.published)


class PatchPokemonRequest(BaseModel):
name: str | None = None
published: bool | None = None

model_config = ConfigDict(extra="forbid")

def as_patch_pokemon_info(self) -> PatchPokemonInfo:
return PatchPokemonInfo(name=self.name, published=self.published)
110 changes: 110 additions & 0 deletions hw3/app/api/pokemon/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from http import HTTPStatus
from typing import Annotated

from fastapi import APIRouter, HTTPException, Query, Response
from pydantic import NonNegativeInt, PositiveInt
from app import store

from .contracts import PatchPokemonRequest, PokemonRequest, PokemonResponse

router = APIRouter(prefix="/pokemon")


@router.get("/")
async def get_pokemon_list(
offset: Annotated[NonNegativeInt, Query()] = 0,
limit: Annotated[PositiveInt, Query()] = 10,
) -> list[PokemonResponse]:
return [PokemonResponse.from_entity(e) for e in store.get_many(offset, limit)]


@router.get(
"/{id}",
responses={
HTTPStatus.OK: {
"description": "Successfully returned requested pokemon",
},
HTTPStatus.NOT_FOUND: {
"description": "Failed to return requested pokemon as one was not found",
},
},
)
async def get_pokemon_by_id(id: int) -> PokemonResponse:
entity = store.get_one(id)

if not entity:
raise HTTPException(
HTTPStatus.NOT_FOUND,
f"Request resource /pokemon/{id} was not found",
)

return PokemonResponse.from_entity(entity)


@router.post(
"/",
status_code=HTTPStatus.CREATED,
)
async def post_pokemon(info: PokemonRequest, response: Response) -> PokemonResponse:
entity = store.add(info.as_pokemon_info())

# as REST states one should provide uri to newly created resource in location header
response.headers["location"] = f"/pokemon/{entity.id}"

return PokemonResponse.from_entity(entity)


@router.patch(
"/{id}",
responses={
HTTPStatus.OK: {
"description": "Successfully patched pokemon",
},
HTTPStatus.NOT_MODIFIED: {
"description": "Failed to modify pokemon as one was not found",
},
},
)
async def patch_pokemon(id: int, info: PatchPokemonRequest) -> PokemonResponse:
entity = store.patch(id, info.as_patch_pokemon_info())

if entity is None:
raise HTTPException(
HTTPStatus.NOT_MODIFIED,
f"Requested resource /pokemon/{id} was not found",
)

return PokemonResponse.from_entity(entity)


@router.put(
"/{id}",
responses={
HTTPStatus.OK: {
"description": "Successfully updated or upserted pokemon",
},
HTTPStatus.NOT_MODIFIED: {
"description": "Failed to modify pokemon as one was not found",
},
},
)
async def put_pokemon(
id: int,
info: PokemonRequest,
upsert: Annotated[bool, Query()] = False,
) -> PokemonResponse:
entity = store.upsert(id, info.as_pokemon_info()) if upsert else store.update(id, info.as_pokemon_info())

if entity is None:
raise HTTPException(
HTTPStatus.NOT_MODIFIED,
f"Requested resource /pokemon/{id} was not found",
)

return PokemonResponse.from_entity(entity)


@router.delete("/{id}")
async def delete_pokemon(id: int) -> Response:
store.delete(id)
return Response("")
10 changes: 10 additions & 0 deletions hw3/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from fastapi import FastAPI
from prometheus_fastapi_instrumentator import Instrumentator
from app.api.pokemon import router as pokemon_router

app = FastAPI(title="Pokemon REST API Example")

app.include_router(pokemon_router)

instrumentator = Instrumentator().instrument(app)
instrumentator.expose(app)
15 changes: 15 additions & 0 deletions hw3/app/store/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from .models import PatchPokemonInfo, PokemonEntity, PokemonInfo
from .queries import add, delete, get_many, get_one, patch, update, upsert

__all__ = [
"PokemonEntity",
"PokemonInfo",
"PatchPokemonInfo",
"add",
"delete",
"get_many",
"get_one",
"update",
"upsert",
"patch",
]
19 changes: 19 additions & 0 deletions hw3/app/store/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from dataclasses import dataclass


@dataclass(slots=True)
class PokemonInfo:
name: str
published: bool


@dataclass(slots=True)
class PokemonEntity:
id: int
info: PokemonInfo


@dataclass(slots=True)
class PatchPokemonInfo:
name: str | None = None
published: bool | None = None
71 changes: 71 additions & 0 deletions hw3/app/store/queries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from typing import Iterable

from app.store.models import PatchPokemonInfo, PokemonEntity, PokemonInfo

_data = dict[int, PokemonInfo]()


def int_id_generator() -> Iterable[int]:
i = 0
while True:
yield i
i += 1


_id_generator = int_id_generator()


def add(info: PokemonInfo) -> PokemonEntity:
_id = next(_id_generator)
_data[_id] = info

return PokemonEntity(_id, info)


def delete(id: int) -> None:
if id in _data:
del _data[id]


def get_one(id: int) -> PokemonEntity | None:
if id not in _data:
return None

return PokemonEntity(id=id, info=_data[id])


def get_many(offset: int = 0, limit: int = 10) -> Iterable[PokemonEntity]:
curr = 0
for id, info in _data.items():
if offset <= curr < offset + limit:
yield PokemonEntity(id, info)

curr += 1


def update(id: int, info: PokemonInfo) -> PokemonEntity | None:
if id not in _data:
return None

_data[id] = info

return PokemonEntity(id=id, info=info)


def upsert(id: int, info: PokemonInfo) -> PokemonEntity:
_data[id] = info

return PokemonEntity(id=id, info=info)


def patch(id: int, patch_info: PatchPokemonInfo) -> PokemonEntity | None:
if id not in _data:
return None

if patch_info.name is not None:
_data[id].name = patch_info.name

if patch_info.published is not None:
_data[id].published = patch_info.published

return PokemonEntity(id=id, info=_data[id])
33 changes: 33 additions & 0 deletions hw3/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
services:
app:
build: .
container_name: fastapi_app
ports:
- "8000:8000"
networks:
- monitoring
restart: always

prometheus:
image: prom/prometheus:latest
container_name: prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
networks:
- monitoring
restart: always

grafana:
image: grafana/grafana:latest
container_name: grafana
ports:
- "3000:3000"
networks:
- monitoring
restart: always

networks:
monitoring:
driver: bridge
7 changes: 7 additions & 0 deletions hw3/prometheus.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
global:
scrape_interval: 5s

scrape_configs:
- job_name: "fastapi_app"
static_configs:
- targets: ["app:8000"]
4 changes: 4 additions & 0 deletions hw3/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fastapi>=0.117.1
uvicorn[standard]
prometheus_client
prometheus-fastapi-instrumentator