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
34 changes: 34 additions & 0 deletions Solution/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# ─── Build stage ──────────────────────────────────────────────────────────────
FROM python:3.12-slim AS builder

WORKDIR /app

# Instala dependências de sistema necessárias para compilar asyncpg
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .

RUN pip install --upgrade pip \
&& pip install --no-cache-dir --prefix=/install -r requirements.txt

# ─── Runtime stage ────────────────────────────────────────────────────────────
FROM python:3.12-slim AS runtime

WORKDIR /app

# Copia apenas as dependências já instaladas do builder
COPY --from=builder /install /usr/local

# Copia o código da aplicação
COPY . .

EXPOSE 8000

# Cria usuário não-root para executar a aplicação
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser

CMD ["uvicorn", "run:app", "--host", "0.0.0.0", "--port", "8000"]
102 changes: 102 additions & 0 deletions Solution/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Signal Processing API

## Pré-requisitos

- [Docker](https://docs.docker.com/get-docker/) e [Docker Compose](https://docs.docker.com/compose/install/)

---

## Configuração

Copie o arquivo de exemplo e ajuste as variáveis conforme necessário:

```bash
cp .env.example .env
```

> O `.env` já vem pré-configurado para funcionar com o Docker Compose sem alterações.

---

## Subindo a aplicação

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

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

> Na primeira execução as tabelas são criadas automaticamente no banco.

---

## Banco de Dados

**PostgreSQL 16** — gerenciado via SQLAlchemy.

| Tabela | Descrição |
|--------|-----------|
| `time_series` | Metadados e métricas pré-computadas de cada série (min, max, média, violações) |
| `time_series_points` | Pontos individuais de cada série (`series_id`, `ts`, `value`) |

---

## Endpoints

| Método | Rota | Descrição |
|--------|------|-----------|
| `POST` | `/api/v1/signal/series` | Cria uma nova série temporal com seus pontos e métricas pré-computadas |
| `GET` | `/api/v1/signal/series/count` | Retorna o total de séries armazenadas |
| `GET` | `/api/v1/signal/series/{series_id}/data` | Retorna os pontos de uma série de forma paginada (`?offset=0`) |
| `GET` | `/api/v1/signal/metrics/{series_id}` | Retorna as métricas de uma série (min, max, média, violações) |
| `DELETE` | `/api/v1/signal/series/{series_id}` | Remove uma série e todos os seus pontos |


Documentação interativa (Swagger): **http://localhost:8000/docs**

---

## Rodando os testes

```bash
# Sem Docker (requer ambiente Python local com dependências instaladas)
# Linux
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pytest -v
```

---

## Parando a aplicação

```bash
docker compose down # para e remove os containers
docker compose down -v # também remove o volume do banco
```

TODO:

- Inserir nova rota que retorna os ids das series presentes no banco.
- Talvez trocar timestamp do ponto da serie para os segundos desde 1970, provavelmente terá uma redução percebida na performance de escrita e armazenamento (provavelmente aumentaria o tempo de leitura para organizar o timestamp em data hora)...
- Nao foi inserido alembic para trabalhar com migrations, a criação da tabela é feita pelo lifespan da aplicação, para escalar pode nao ser uma boa caso duas aplicacoes subam ao mesmo tempo tentando criar tabela no banco...
- Criar CI.
- A Aplicação foi montada pensando numa modularização clara router->service->repository router cuida de validacoes de entrada e saida da rota, service cuida da logica da aplicacao e como os dados serão persistidos e repository cuida da persistencia e leitura em banco. Esse modelo facilita os testes automatizados, para nao utilizar o banco em testes automatizados de integracao parcial, consigo mockar as chamadas pro repository.
- Subir a cobertura de testes.
- Atualmente a resposta pro client na criação de uma serie está sincrono, devolvemos detalhes da inserção como id registrado em banco após inserção, dependendo como essa aplicação crescer isso não será escalavel, dado que poderá haver outras comunicações durante a inserção, como por exemplo o acionamento para um servico de alarme ao perceber que há registros de pontos da serie violados para o objeto medido... Uma possivel solução seria criar uma fila de mensagens como o rabbitMQ e worker consumindo a fila para o registro em banco.
- Não foram realizados testes de carga na aplicacao, nem teste com aplicação hospedada em nuvem.
- O calculo das metricas pré computadas na criação da série não está redondo para teste, porque ao mesmo tempo que calcula as metricas com base no payload (isso na camada service logic busines) faz chamada de repository para persistir a serie em banco. Ainda é testavel mas precisará de um mock para o repository... Talvez o ideal fosse tornar uma funcao o calculo de metricas...
- Insercao de log na aplicacao.
- Criar os testes parciais com subida de banco. Nesse teste será preciso criar uma fixture que permita a utilizacao do lifespan pra criacao das tabelas. Depois alterado pra alembic.

resultados POST POSTMAN maquina local

pontos tempo total ms
1500 193.74


GET paginado

pontos totais offset 150 pontos paginados
1500 10 ms
71 changes: 71 additions & 0 deletions Solution/app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

from app.infra.database import Base, engine
from app.routes.v1 import v1_router

# Garante que os modelos SQLAlchemy sejam registrados no Base.metadata
import app.infra.time_series # noqa: F401

logger = logging.getLogger(__name__)


def create_app(testing: bool = False) -> FastAPI:

@asynccontextmanager
async def lifespan(app: FastAPI):
"""Cria as tabelas no banco na inicialização, se ainda não existirem.

Usa CREATE TABLE IF NOT EXISTS do PostgreSQL — operação atômica e segura
Isso aqui nao escala muito bem aparentemente (duas instancias podem subir ao mesmo tempo e tentar criar a tabela), mas é suficiente para o que foi implementado do teste. Em produção, eu usaria uma ferramenta de migração como Alembic.
"""
# Modo de teste: nao cria tabelas, pois o repositório é 100% mockado e nao precisa de um banco real.
# Para um teste de integracao com banco real, bastaria criar um conftest que cria o app com testing=False e um banco de teste configurado, e nao mockar o repositorio. Assim, as tabelas seriam criadas normalmente e o teste usaria um banco real, mas isolado do ambiente de desenvolvimento/produção.
if not testing:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
if not testing:
await engine.dispose()

app = FastAPI(
title="Signal Processing API",
version="1.0.0",
lifespan=lifespan,
)
app.include_router(v1_router, prefix="/api/v1")

# Assegura que o FastAPI nao vaze campo padrao que mostre os dados de entrada do payload, que pode conter dados sensiveis.
# O RequestValidationError é o erro lançado pelo FastAPI quando o payload nao bate com o modelo Pydantic esperado.
# Alem de seguranca, isso torna a resposta mais leve, dado que o payload pode ser grande (pontos da serie).
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"detail": [
{
"type": error["type"],
"loc": error["loc"],
"msg": error["msg"],
}
for error in exc.errors()
]
},
)

# Assegura que erros nao tratados sejam convertidos em respostas JSON genéricas, sem vazar detalhes do erro ou dados de entrada.
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
logger.exception("Erro não tratado: %s", exc)
return JSONResponse(
status_code=500,
content={"detail": "Erro interno do servidor."},
)

return app
27 changes: 27 additions & 0 deletions Solution/app/infra/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from collections.abc import AsyncGenerator
import os

from dotenv import load_dotenv
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base

load_dotenv()

DATABASE_URL = os.getenv("DATABASE_URL")

engine = create_async_engine(DATABASE_URL, echo=True)

AsyncSessionLocal = async_sessionmaker(
engine,
expire_on_commit=False
)

Base = declarative_base()

async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Dependency to get database session"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
48 changes: 48 additions & 0 deletions Solution/app/infra/time_series.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Float, Integer, BigInteger, ForeignKey, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, Mapped, mapped_column
from app.infra.database import Base

class TimeSeries(Base):
__tablename__ = "time_series"

id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String, nullable=False)
source: Mapped[str | None] = mapped_column(String, nullable=True)
unit: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
start_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
points_count: Mapped[int] = mapped_column(Integer, nullable=False)
min_value: Mapped[float] = mapped_column(Float, nullable=False)
max_value: Mapped[float] = mapped_column(Float, nullable=False)
average: Mapped[float] = mapped_column(Float, nullable=False)
min_value_aceptable_violated_count: Mapped[int] = mapped_column(Integer, nullable=False)
max_value_aceptable_violated_count: Mapped[int] = mapped_column(Integer, nullable=False)

points = relationship(
"TimeSeriesPoint",
back_populates="series",
cascade="all, delete-orphan",
passive_deletes=True,
)

class TimeSeriesPoint(Base):
__tablename__ = "time_series_points"
__table_args__ = (
UniqueConstraint("series_id", "ts", name="uq_series_ts"),
)

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
series_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("time_series.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
value: Mapped[float] = mapped_column(Float, nullable=False)

series = relationship("TimeSeries", back_populates="points")
53 changes: 53 additions & 0 deletions Solution/app/models/signal_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List

class PointIn(BaseModel):
model_config = ConfigDict(from_attributes=True)

ts: datetime
value: float

class CreateSeriesRequest(BaseModel):
"""Schema to create a new series."""
name: str = Field(..., min_length=1, max_length=255)
source: str = Field(..., max_length=255)
unit: Optional[str] = Field(default=None, max_length=50)
points: List[PointIn] = Field(..., min_length=1)

class SeriesResponse(BaseModel):
"""Schema for the response of a series created."""
id: UUID
name: str
source: str
unit: Optional[str]
points_count: int
start_ts: datetime
end_ts: datetime
created_at: datetime
min_value: float
max_value: float
average: float
min_value_aceptable_violated_count: int
max_value_aceptable_violated_count: int

class NumberOfSeriesResponse(BaseModel):
"""Schema for the response of the number of series stored in the server."""
count: int

class FullSeriesResponse(SeriesResponse):
"""Schema for the response of a full series, with all the points."""
id: UUID
points: List[PointIn]

class PaginatedSeriesResponse(BaseModel):
"""Schema for the paginated response of a time series, with a subset of its points.

Pass `next_offset` as the `offset` query param in the next request.
When `has_more` is False, `next_offset` is None and pagination is complete.
"""
series_id: UUID
data: List[PointIn]
has_more: bool
next_offset: Optional[int]
Loading