Skip to content
Open
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
17 changes: 17 additions & 0 deletions gedson-silva/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
*.env.prod
*.env
.vscode
*.json
pytest_cache/
.pytest_cache

guide.md

*.db
*.db-shm
*db-wal

venv/
__pycache__/
*.pyc
.env
10 changes: 10 additions & 0 deletions gedson-silva/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
103 changes: 103 additions & 0 deletions gedson-silva/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Dynamox Time-Series API

API REST para armazenamento e processamento de séries temporais, desenvolvida com FastAPI, PostgreSQL e Docker.

## Pré-requisitos

- Python 3.11+
- Docker e Docker Compose
- Git

## Como rodar

### 1. Clone o repositório

```bash
git clone https://github.com/seu-usuario/developer-challenges.git
cd developer-challenges/gedson-silva
```

### 2. Local (SQLite)

```bash
pip install -r requirements.txt
uvicorn app.main:app --reload
```

## Documentação interativa

### Ambiente local

Após subir a aplicação localmente, acesse:

`http://localhost:8000/docs`

### Ambiente de produção

A documentação da API publicada para avaliação do desafio está disponível em:

`https://developer-challenges-qz0n.onrender.com/docs`

Através dessa interface é possível visualizar todos os endpoints, schemas, exemplos de requisição e testar a API diretamente pelo navegador.


### 3. Docker (PostgreSQL + Nginx + 3 réplicas)

```bash
docker compose up --build
```

Sobe automaticamente:
- 3 instâncias da API
- PostgreSQL 16
- Nginx como load balancer na porta 8000

A API estará disponível em `http://localhost:8000`.

## Endpoints

| Método | Rota | Descrição |
|--------|------|-----------|
| POST | /api/v1/series/ | Criar série |
| GET | /api/v1/series/count | Contar séries |
| GET | /api/v1/series/{id} | Buscar série completa |
| GET | /api/v1/series/{id}/metrics | Métricas estatísticas |
| GET | /api/v1/series/{id}/predict?steps=N | Predição futura (padrão: 10 passos) |
| DELETE | /api/v1/series/{id} | Deletar série |

## Documentação interativa

Acesse `http://localhost:8000/docs` após subir a API.

## Testes

```bash
pip install -r requirements.txt
pytest tests/ -v
```

13 testes cobrindo happy path e casos de borda (404, 422, validações).

## Load Tests

Testes de carga realizados com [Locust](https://locust.io/) com 50 usuários simultâneos.

### Rodar load tests

```bash
pip install locust
locust -f locustfile.py --host=http://localhost:8000
```

Acesse `http://localhost:8089` para configurar e monitorar.

### Resultados

| Endpoint | Mediana (ms) | Percentil 95 (ms) | Falhas |
|---|---|---|---|
| GET /series/{id} | 9 | ~310 | 0% |
| GET /series/{id}/metrics | 10–12 | ~20 | 0% |
| POST /series/ | 5900 | 7200 | 0% |

> Todos os endpoints GET responderam bem abaixo do requisito de 350ms.
> A latência do POST reflete a criação simultânea de 50 séries no startup do teste, não o uso típico.
Empty file added gedson-silva/alembic.ini
Empty file.
Empty file added gedson-silva/alembic/env.py
Empty file.
10 changes: 10 additions & 0 deletions gedson-silva/app/core/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
DATABASE_URL: str = "sqlite://./timeseries.db"
APP_ENV: str = "development"

class ConfigDict:
env_file = ".env"

settings = Settings()
11 changes: 11 additions & 0 deletions gedson-silva/app/core/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from app.core.config import settings

TORTOISE_ORM = {
"connections": {"default": settings.DATABASE_URL},
"apps": {
"models": {
"models": ["app.models.models", "aerich.models"],
"default_connection": "default",
},
},
}
6 changes: 6 additions & 0 deletions gedson-silva/app/enums/messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from enum import StrEnum

class TimeSeriesMessage(StrEnum):
NOT_FOUND = "Série não encontrada"
CONFLICT = "Registro duplicado"
INSUFFICIENT_POINTS = "Mínimo de 2 pontos para predição"
22 changes: 22 additions & 0 deletions gedson-silva/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from fastapi import FastAPI
from tortoise.contrib.fastapi import register_tortoise
from app.routers.api import router as series_router
from app.core.database import TORTOISE_ORM

app = FastAPI(
title="Dynamox Time-Series API",
version="1.0.0",
)

register_tortoise(
app,
config=TORTOISE_ORM,
generate_schemas=True,
add_exception_handlers=True,
)

app.include_router(series_router)

@app.get("/health")
async def health():
return {"status": "ok"}
26 changes: 26 additions & 0 deletions gedson-silva/app/models/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import uuid
from tortoise import fields
from tortoise.fields import CASCADE
from tortoise.models import Model

class TimeSeries(Model):
id = fields.CharField(primary_key=True, max_length=36, default=lambda: str(uuid.uuid4()))
name = fields.CharField(max_length=255, null=True)
created_at = fields.DatetimeField(auto_now_add=True)

points: fields.ReverseRelation["DataPoint"]

class Meta: # type: ignore
table = "time_series"


class DataPoint(Model):
id = fields.CharField(primary_key=True, max_length=36, default=lambda: str(uuid.uuid4()))
series: fields.ForeignKeyRelation["TimeSeries"] = fields.ForeignKeyField(
"models.TimeSeries", related_name="points", on_delete=CASCADE
)
timestamp = fields.DatetimeField()
value = fields.FloatField()

class Meta: # type: ignore
table = "data_points"
23 changes: 23 additions & 0 deletions gedson-silva/app/repositories/time_series.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from app.models.models import TimeSeries, DataPoint
from app.schemas.requests import TimeSeriesCreate


class TimeSeriesRepository:

async def create(self, data: TimeSeriesCreate) -> TimeSeries:
series = await TimeSeries.create(name=data.name)
await DataPoint.bulk_create([
DataPoint(series=series, timestamp=p.timestamp, value=p.value)
for p in data.points
])
await series.fetch_related("points")
return series

async def get_by_id(self, series_id: str) -> TimeSeries | None:
return await TimeSeries.get_or_none(id=series_id).prefetch_related("points")

async def delete(self, series: TimeSeries) -> None:
await series.delete()

async def count(self) -> int:
return await TimeSeries.all().count()
58 changes: 58 additions & 0 deletions gedson-silva/app/routers/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from tortoise.exceptions import IntegrityError
from fastapi import APIRouter, HTTPException, Query
from app.repositories.time_series import TimeSeriesRepository
from app.schemas.requests import TimeSeriesCreate
from app.schemas.responses import CountResponse, TimeSeriesMetrics, TimeSeriesOut, TimeSeriesPrediction
from app.services.time_series import calculate_metrics
from app.services.prediction import predict_linear
from fastapi import status
from app.services.get_or_not_found import get_series_or_404

router = APIRouter(prefix="/api/v1/series", tags=["Time Series"])
repo = TimeSeriesRepository()


@router.post("/", status_code=201, response_model=TimeSeriesOut)
async def create_series(data: TimeSeriesCreate):
try:
instance_created = await repo.create(data)
except IntegrityError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
return instance_created


@router.get("/count", response_model=CountResponse)
async def count_series():
total = await repo.count()
return CountResponse(count=total)


@router.get("/{series_id}", response_model=TimeSeriesOut)
async def get_series(series_id: str):
return await get_series_or_404(series_id)


@router.get("/{series_id}/metrics", response_model=TimeSeriesMetrics)
async def get_metrics(series_id: str):
series = await get_series_or_404(series_id)
return calculate_metrics(series)


@router.get("/{series_id}/predict", response_model=TimeSeriesPrediction)
async def predict_series(
series_id: str,
steps: int = Query(default=10, ge=1, le=100),
):
series = await get_series_or_404(series_id)
try:
return predict_linear(series, steps)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e))



@router.delete("/{series_id}", status_code=204)
async def delete_series(series_id: str):
series = await get_series_or_404(series_id)
await repo.delete(series)

18 changes: 18 additions & 0 deletions gedson-silva/app/schemas/requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from pydantic import BaseModel, field_validator
from datetime import datetime


class DataPointIn(BaseModel):
timestamp: datetime
value: float

class TimeSeriesCreate(BaseModel):
name: str | None = None
points: list[DataPointIn]

@field_validator("points")
@classmethod
def must_have_points(cls, v):
if len(v) == 0:
raise ValueError("A série deve ter pelo menos 1 ponto")
return v
39 changes: 39 additions & 0 deletions gedson-silva/app/schemas/responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from pydantic import BaseModel, field_validator
from datetime import datetime

class DataPointOut(BaseModel):
timestamp: datetime
value: float

model_config = {"from_attributes": True}

class TimeSeriesOut(BaseModel):
id: str
name: str | None
created_at: datetime
points: list[DataPointOut]

model_config = {"from_attributes": True}

class TimeSeriesMetrics(BaseModel):
series_id: str
count: int
min: float
max: float
mean: float
median: float
std: float
range: float

class PredictedPoint(BaseModel):
step: int
value: float

class TimeSeriesPrediction(BaseModel):
series_id: str
method: str
steps: int
predicted_points: list[PredictedPoint]

class CountResponse(BaseModel):
count: int
13 changes: 13 additions & 0 deletions gedson-silva/app/services/get_or_not_found.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from fastapi import HTTPException
from fastapi import status
from app.enums.messages import TimeSeriesMessage
from app.models.models import TimeSeries
from app.repositories.time_series import TimeSeriesRepository


async def get_series_or_404(series_id: str) -> TimeSeries:
repo = TimeSeriesRepository()
series = await repo.get_by_id(series_id)
if not series:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=TimeSeriesMessage.NOT_FOUND)
return series
Loading