APIs RESTful completas: projeto do zero ao deploy

APIs RESTful completas: projeto do zero ao deploy

API RESTful completa em Go, do zero ao deploy: domínio, migrações, repositório que filtra pelo dono da tarefa, paginação com total correto depois da última página, handler, Dockerfile com a mesma versão do go.mod, Makefile com tabulações e deploy no Fly.io.
Golang

• • 19 min de leitura

Os artigos anteriores (Domain-Driven Design aplicado a projetos Go, Injeção de dependência sem frameworks, Design Patterns em Go: Repository, Factory e Strategy) construíram um repertório sólido: HTTP, banco de dados, autenticação, middlewares, arquitetura limpa e padrões de design. Este artigo une tudo isso em um projeto coeso — uma API RESTful de gerenciamento de tarefas, construída do zero e levada até o deploy.

O projeto seguirá uma estrutura profissional, com autenticação JWT, banco de dados PostgreSQL, testes automatizados, containerização com Docker e deploy em uma plataforma de nuvem.

Estrutura do projeto

taskapi/
├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── domain/
│   │   └── tarefa.go
│   ├── repository/
│   │   └── tarefa.go
│   ├── usecase/
│   │   └── tarefa/
│   │       ├── criar.go
│   │       ├── listar.go
│   │       ├── atualizar.go
│   │       └── deletar.go
│   ├── handler/
│   │   ├── tarefa.go
│   │   └── auth.go
│   ├── middleware/
│   │   ├── auth.go
│   │   └── cors.go
│   └── infra/
│       └── postgres/
│           └── tarefa_repo.go
├── migrations/
│   ├── 000001_criar_usuarios.up.sql
│   ├── 000001_criar_usuarios.down.sql
│   ├── 000002_criar_tarefas.up.sql
│   └── 000002_criar_tarefas.down.sql
├── Dockerfile
├── docker-compose.yml
├── Makefile
├── go.mod
└── go.sum

O artigo mostra o domínio, as migrações, o repositório, o handler, a composição no main e o deploy. Quatro peças que aparecem na árvore e nos imports não são repetidas aqui, porque seguem padrões já mostrados: os casos de uso de internal/usecase/tarefa, no formato de Clean Architecture em Go: camadas e dependências; o middleware JWT, com JWT(segredo) e UsuarioIDDoContext, e o AuthHandler, como em Middlewares: logging, autenticação e CORS; e o repositório de usuários, no mesmo molde do de tarefas. Sem eles, o projeto não compila: o go build ./... aponta cada pacote que falta.

Domínio e entidades

// internal/domain/tarefa.go
package domain

import (
    "errors"
    "time"
)

var (
    ErrTituloDeTarefaVazio  = errors.New("título da tarefa não pode ser vazio")
    ErrTarefaNaoEncontrada  = errors.New("tarefa não encontrada")
    ErrTarefaSemPermissao   = errors.New("sem permissão para esta tarefa")
)

type PrioridadeTarefa int

const (
    PrioridadeBaixa  PrioridadeTarefa = 1
    PrioridadeMedia  PrioridadeTarefa = 2
    PrioridadeAlta   PrioridadeTarefa = 3
    PrioridadeUrgente PrioridadeTarefa = 4
)

type Tarefa struct {
    ID          int64
    UsuarioID   int64
    Titulo      string
    Descricao   string
    Concluida   bool
    Prioridade  PrioridadeTarefa
    Prazo       *time.Time
    CriadoEm   time.Time
    AtualizadoEm time.Time
}

func NovaTarefa(usuarioID int64, titulo, descricao string, prioridade PrioridadeTarefa, prazo *time.Time) (*Tarefa, error) {
    if titulo == "" {
        return nil, ErrTituloDeTarefaVazio
    }
    if prioridade < PrioridadeBaixa || prioridade > PrioridadeUrgente {
        prioridade = PrioridadeMedia
    }
    agora := time.Now().UTC()
    return &Tarefa{
        UsuarioID:    usuarioID,
        Titulo:       titulo,
        Descricao:    descricao,
        Prioridade:   prioridade,
        Prazo:        prazo,
        CriadoEm:    agora,
        AtualizadoEm: agora,
    }, nil
}

func (t *Tarefa) Concluir() {
    t.Concluida = true
    t.AtualizadoEm = time.Now().UTC()
}

func (t *Tarefa) PertenceAo(usuarioID int64) bool {
    return t.UsuarioID == usuarioID
}

Migrações SQL

-- migrations/000001_criar_usuarios.up.sql
CREATE TABLE usuarios (
    id         BIGSERIAL PRIMARY KEY,
    nome       TEXT NOT NULL,
    email      TEXT NOT NULL UNIQUE,
    senha_hash TEXT NOT NULL,
    ativo      BOOLEAN NOT NULL DEFAULT TRUE,
    criado_em  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- migrations/000002_criar_tarefas.up.sql
CREATE TABLE tarefas (
    id            BIGSERIAL PRIMARY KEY,
    usuario_id    BIGINT NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, -- tarefa não existe sem o dono
    titulo        TEXT NOT NULL,
    descricao     TEXT NOT NULL DEFAULT '',
    concluida     BOOLEAN NOT NULL DEFAULT FALSE,
    prioridade    INT NOT NULL DEFAULT 2
                  CHECK(prioridade BETWEEN 1 AND 4),
    prazo         TIMESTAMPTZ,
    criado_em     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tarefas_usuario_id ON tarefas(usuario_id);
CREATE INDEX idx_tarefas_concluida ON tarefas(usuario_id, concluida);
CREATE INDEX idx_tarefas_prazo ON tarefas(prazo) WHERE prazo IS NOT NULL;

Contrato do repositório

A interface fica em internal/repository, e o filtro usa ponteiros para distinguir "sem filtro" de um valor:

// internal/repository/tarefa.go
package repository

import (
    "context"

    "taskapi/internal/domain"
)

// FiltroTarefa: os ponteiros nil significam "sem filtro".
type FiltroTarefa struct {
    Concluida  *bool
    Prioridade *int
    Pagina     int // começa em 1
    Limite     int
}

type TarefaRepository interface {
    Criar(ctx context.Context, t *domain.Tarefa) error
    BuscarPorID(ctx context.Context, id, usuarioID int64) (*domain.Tarefa, error)
    Listar(ctx context.Context, usuarioID int64, f FiltroTarefa) ([]*domain.Tarefa, int64, error)
    Atualizar(ctx context.Context, t *domain.Tarefa) error
    Deletar(ctx context.Context, id, usuarioID int64) error
}

Repositório

// internal/infra/postgres/tarefa_repo.go
package postgres

import (
    "context"
    "errors"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"

    "taskapi/internal/domain"
    "taskapi/internal/repository"
)

type tarefaRepo struct{ db *pgxpool.Pool }

func NovoTarefaRepo(db *pgxpool.Pool) repository.TarefaRepository {
    return &tarefaRepo{db: db}
}

func (r *tarefaRepo) Criar(ctx context.Context, t *domain.Tarefa) error {
    query := `
        INSERT INTO tarefas (usuario_id, titulo, descricao, prioridade, prazo)
        VALUES ($1, $2, $3, $4, $5)
        RETURNING id, criado_em, atualizado_em
    `
    return r.db.QueryRow(ctx, query,
        t.UsuarioID, t.Titulo, t.Descricao, t.Prioridade, t.Prazo,
    ).Scan(&t.ID, &t.CriadoEm, &t.AtualizadoEm)
}

func (r *tarefaRepo) BuscarPorID(ctx context.Context, id, usuarioID int64) (*domain.Tarefa, error) {
    query := `
        SELECT id, usuario_id, titulo, descricao, concluida,
               prioridade, prazo, criado_em, atualizado_em
        FROM tarefas
        WHERE id = $1 AND usuario_id = $2
    `
    var t domain.Tarefa
    err := r.db.QueryRow(ctx, query, id, usuarioID).Scan(
        &t.ID, &t.UsuarioID, &t.Titulo, &t.Descricao, &t.Concluida,
        &t.Prioridade, &t.Prazo, &t.CriadoEm, &t.AtualizadoEm,
    )
    if errors.Is(err, pgx.ErrNoRows) {
        return nil, domain.ErrTarefaNaoEncontrada
    }
    return &t, err
}

func (r *tarefaRepo) Listar(ctx context.Context, usuarioID int64, f repository.FiltroTarefa) ([]*domain.Tarefa, int64, error) {
    // O handler já valida, mas o repositório não deve depender disso:
    // página 0 viraria OFFSET negativo, recusado pelo PostgreSQL
    if f.Pagina < 1 {
        f.Pagina = 1
    }
    if f.Limite < 1 {
        f.Limite = 20
    }

    query := `
        SELECT id, usuario_id, titulo, descricao, concluida,
               prioridade, prazo, criado_em, atualizado_em,
               COUNT(*) OVER() AS total
        FROM tarefas
        WHERE usuario_id = $1
          AND ($2::boolean IS NULL OR concluida = $2)
          AND ($3::int IS NULL OR prioridade = $3)
        ORDER BY prioridade DESC, criado_em DESC
        LIMIT $4 OFFSET $5
    `
    rows, err := r.db.Query(ctx, query,
        usuarioID, f.Concluida, f.Prioridade,
        f.Limite, (f.Pagina-1)*f.Limite,
    )
    if err != nil {
        return nil, 0, err
    }
    defer rows.Close()

    var tarefas []*domain.Tarefa
    var total int64
    for rows.Next() {
        var t domain.Tarefa
        if err := rows.Scan(
            &t.ID, &t.UsuarioID, &t.Titulo, &t.Descricao, &t.Concluida,
            &t.Prioridade, &t.Prazo, &t.CriadoEm, &t.AtualizadoEm, &total,
        ); err != nil {
            return nil, 0, err
        }
        tarefas = append(tarefas, &t)
    }
    if err := rows.Err(); err != nil {
        return nil, 0, err
    }

    // Depois da última página não há linhas, e o COUNT(*) OVER() não tem
    // onde aparecer: o total viria 0. Nesse caso, ele precisa de consulta própria
    if len(tarefas) == 0 && f.Pagina > 1 {
        err := r.db.QueryRow(ctx, `
            SELECT COUNT(*) FROM tarefas
            WHERE usuario_id = $1
              AND ($2::boolean IS NULL OR concluida = $2)
              AND ($3::int IS NULL OR prioridade = $3)
        `, usuarioID, f.Concluida, f.Prioridade).Scan(&total)
        if err != nil {
            return nil, 0, err
        }
    }
    return tarefas, total, nil
}

func (r *tarefaRepo) Atualizar(ctx context.Context, t *domain.Tarefa) error {
    query := `
        UPDATE tarefas
        SET titulo = $1, descricao = $2, concluida = $3,
            prioridade = $4, prazo = $5, atualizado_em = NOW()
        WHERE id = $6 AND usuario_id = $7
        RETURNING atualizado_em
    `
    err := r.db.QueryRow(ctx, query,
        t.Titulo, t.Descricao, t.Concluida,
        t.Prioridade, t.Prazo, t.ID, t.UsuarioID,
    ).Scan(&t.AtualizadoEm)
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.ErrTarefaNaoEncontrada
    }
    return err
}

func (r *tarefaRepo) Deletar(ctx context.Context, id, usuarioID int64) error {
    result, err := r.db.Exec(ctx,
        "DELETE FROM tarefas WHERE id = $1 AND usuario_id = $2", id, usuarioID)
    if err != nil {
        return err
    }
    if result.RowsAffected() == 0 {
        return domain.ErrTarefaNaoEncontrada
    }
    return nil
}

Handler HTTP completo

// internal/handler/tarefa.go
package handler

import (
    "encoding/json"
    "errors"
    "net/http"
    "strconv"
    "time"

    "github.com/go-chi/chi/v5"

    "taskapi/internal/domain"
    "taskapi/internal/middleware"
    "taskapi/internal/repository"
    "taskapi/internal/usecase/tarefa"
)

type TarefaHandler struct {
    criar     *tarefa.CriarTarefa
    listar    *tarefa.ListarTarefas
    atualizar *tarefa.AtualizarTarefa
    deletar   *tarefa.DeletarTarefa
}

func NovoTarefaHandler(
    criar *tarefa.CriarTarefa,
    listar *tarefa.ListarTarefas,
    atualizar *tarefa.AtualizarTarefa,
    deletar *tarefa.DeletarTarefa,
) *TarefaHandler {
    return &TarefaHandler{
        criar: criar, listar: listar,
        atualizar: atualizar, deletar: deletar,
    }
}

type criarTarefaReq struct {
    Titulo     string    `json:"titulo"`
    Descricao  string    `json:"descricao"`
    Prioridade int       `json:"prioridade"`
    Prazo      *time.Time `json:"prazo,omitempty"`
}

type atualizarTarefaReq struct {
    Titulo     string    `json:"titulo"`
    Descricao  string    `json:"descricao"`
    Concluida  bool      `json:"concluida"`
    Prioridade int       `json:"prioridade"`
    Prazo      *time.Time `json:"prazo,omitempty"`
}

func (h *TarefaHandler) Criar(w http.ResponseWriter, r *http.Request) {
    usuarioID := middleware.UsuarioIDDoContext(r.Context())

    var req criarTarefaReq
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        responderErro(w, http.StatusBadRequest, "corpo inválido")
        return
    }

    t, err := h.criar.Executar(r.Context(), tarefa.CriarInput{
        UsuarioID:  usuarioID,
        Titulo:     req.Titulo,
        Descricao:  req.Descricao,
        Prioridade: domain.PrioridadeTarefa(req.Prioridade),
        Prazo:      req.Prazo,
    })
    if err != nil {
        traduzirErro(w, err)
        return
    }

    responderJSON(w, http.StatusCreated, t)
}

func (h *TarefaHandler) Listar(w http.ResponseWriter, r *http.Request) {
    usuarioID := middleware.UsuarioIDDoContext(r.Context())

    q := r.URL.Query()
    pagina, _ := strconv.Atoi(q.Get("pagina"))
    if pagina < 1 {
        pagina = 1
    }
    limite, _ := strconv.Atoi(q.Get("limite"))
    if limite < 1 || limite > 100 {
        limite = 20
    }

    var concluida *bool
    if v := q.Get("concluida"); v != "" {
        b := v == "true"
        concluida = &b
    }

    resultado, err := h.listar.Executar(r.Context(), tarefa.ListarInput{
        UsuarioID: usuarioID,
        Filtro: repository.FiltroTarefa{
            Concluida: concluida,
            Pagina:    pagina,
            Limite:    limite,
        },
    })
    if err != nil {
        responderErro(w, http.StatusInternalServerError, "erro ao listar tarefas")
        return
    }

    w.Header().Set("X-Total-Count", strconv.FormatInt(resultado.Total, 10))
    responderJSON(w, http.StatusOK, resultado.Tarefas)
}

func (h *TarefaHandler) Atualizar(w http.ResponseWriter, r *http.Request) {
    usuarioID := middleware.UsuarioIDDoContext(r.Context())
    id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
    if err != nil {
        responderErro(w, http.StatusBadRequest, "id inválido")
        return
    }

    var req atualizarTarefaReq
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        responderErro(w, http.StatusBadRequest, "corpo inválido")
        return
    }

    t, err := h.atualizar.Executar(r.Context(), tarefa.AtualizarInput{
        ID:         id,
        UsuarioID:  usuarioID,
        Titulo:     req.Titulo,
        Descricao:  req.Descricao,
        Concluida:  req.Concluida,
        Prioridade: domain.PrioridadeTarefa(req.Prioridade),
        Prazo:      req.Prazo,
    })
    if err != nil {
        traduzirErro(w, err)
        return
    }

    responderJSON(w, http.StatusOK, t)
}

func (h *TarefaHandler) Deletar(w http.ResponseWriter, r *http.Request) {
    usuarioID := middleware.UsuarioIDDoContext(r.Context())
    id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
    if err != nil {
        responderErro(w, http.StatusBadRequest, "id inválido")
        return
    }

    if err := h.deletar.Executar(r.Context(), id, usuarioID); err != nil {
        traduzirErro(w, err)
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

func traduzirErro(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, domain.ErrTarefaNaoEncontrada):
        responderErro(w, http.StatusNotFound, err.Error())
    case errors.Is(err, domain.ErrTituloDeTarefaVazio):
        responderErro(w, http.StatusUnprocessableEntity, err.Error())
    case errors.Is(err, domain.ErrTarefaSemPermissao):
        responderErro(w, http.StatusForbidden, err.Error())
    default:
        responderErro(w, http.StatusInternalServerError, "erro interno")
    }
}

func responderJSON(w http.ResponseWriter, status int, dados any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(dados)
}

func responderErro(w http.ResponseWriter, status int, msg string) {
    responderJSON(w, status, map[string]string{"erro": msg})
}

main.go: composição completa

// cmd/api/main.go
package main

import (
    "context"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/go-chi/chi/v5"
    chimw "github.com/go-chi/chi/v5/middleware"
    "github.com/golang-jwt/jwt/v5"
    "github.com/jackc/pgx/v5/pgxpool"
    _ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
    _ "github.com/golang-migrate/migrate/v4/source/file"

    "taskapi/internal/handler"
    "taskapi/internal/infra/postgres"
    "taskapi/internal/middleware"
    "taskapi/internal/usecase/tarefa"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)

    dbURL := os.Getenv("DATABASE_URL")
    if dbURL == "" {
        logger.Error("DATABASE_URL não definida")
        os.Exit(1)
    }

    jwtSegredo := os.Getenv("JWT_SECRET")
    if jwtSegredo == "" {
        logger.Error("JWT_SECRET não definida")
        os.Exit(1)
    }

    // Banco de dados
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, dbURL)
    if err != nil {
        logger.Error("falha ao conectar ao banco", "err", err)
        os.Exit(1)
    }
    defer pool.Close()

    if err := pool.Ping(ctx); err != nil {
        logger.Error("banco inacessível", "err", err)
        os.Exit(1)
    }

    // Repositórios
    tarefaRepo := postgres.NovoTarefaRepo(pool)
    usuarioRepo := postgres.NovoUsuarioRepo(pool)

    // Casos de uso
    criarTarefa := tarefa.NovoCriarTarefa(tarefaRepo)
    listarTarefas := tarefa.NovoListarTarefas(tarefaRepo)
    atualizarTarefa := tarefa.NovoAtualizarTarefa(tarefaRepo)
    deletarTarefa := tarefa.NovoDeletarTarefa(tarefaRepo)

    // Handlers
    tarefaHandler := handler.NovoTarefaHandler(criarTarefa, listarTarefas, atualizarTarefa, deletarTarefa)
    authHandler := handler.NovoAuthHandler(usuarioRepo, jwtSegredo)

    // Roteamento
    r := chi.NewRouter()
    r.Use(chimw.RequestID)
    r.Use(chimw.RealIP)
    r.Use(chimw.Logger)
    r.Use(chimw.Recoverer)
    r.Use(chimw.Timeout(30 * time.Second))

    r.Get("/saude", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprintln(w, `{"status":"ok"}`)
    })

    r.Route("/api/v1", func(r chi.Router) {
        r.Post("/auth/registro", authHandler.Registrar)
        r.Post("/auth/login", authHandler.Login)

        r.Group(func(r chi.Router) {
            r.Use(middleware.JWT(jwtSegredo))

            r.Route("/tarefas", func(r chi.Router) {
                r.Get("/", tarefaHandler.Listar)
                r.Post("/", tarefaHandler.Criar)
                r.Put("/{id}", tarefaHandler.Atualizar)
                r.Delete("/{id}", tarefaHandler.Deletar)
            })
        })
    })

    porta := os.Getenv("PORT")
    if porta == "" {
        porta = "8080"
    }

    srv := &http.Server{
        Addr:         ":" + porta,
        Handler:      r,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    go func() {
        logger.Info("API iniciada", "porta", porta)
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            logger.Error("servidor encerrado com erro", "err", err)
            os.Exit(1)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    logger.Info("encerrando servidor...")
    shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutCtx); err != nil {
        logger.Error("erro no shutdown", "err", err)
    }
    logger.Info("servidor encerrado")
}

Dockerfile multistage

# Dockerfile
# A versão do Go da imagem precisa ser pelo menos a da diretiva go do go.mod
FROM golang:1.27-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -o taskapi ./cmd/api

FROM alpine:3.24
RUN apk --no-cache add ca-certificates tzdata

WORKDIR /app
COPY --from=builder /app/taskapi .
COPY --from=builder /app/migrations ./migrations

EXPOSE 8080
USER nobody:nobody

ENTRYPOINT ["./taskapi"]

docker-compose.yml para desenvolvimento

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://usuario:senha@db:5432/taskapi?sslmode=disable
      JWT_SECRET: dev-secret-troque-em-producao
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: usuario
      POSTGRES_PASSWORD: senha
      POSTGRES_DB: taskapi
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U usuario -d taskapi"]
      interval: 5s
      timeout: 5s
      retries: 5

  migrate:
    image: migrate/migrate
    volumes:
      - ./migrations:/migrations
    command: -path=/migrations -database=postgres://usuario:senha@db:5432/taskapi?sslmode=disable up
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres_data:

Makefile

# As linhas de comando começam com TAB; com espaços, o make para com "missing separator"
.PHONY: dev build test lint migrate-up migrate-down docker-up docker-down

dev:
	air -c .air.toml  # hot reload com air

build:
	CGO_ENABLED=0 go build -o bin/taskapi ./cmd/api

test:
	go test -race -count=1 -cover ./...

test-integration:
	go test -race -count=1 -tags=integration ./...

lint:
	golangci-lint run ./...

migrate-up:
	migrate -path migrations -database "${DATABASE_URL}" up

migrate-down:
	migrate -path migrations -database "${DATABASE_URL}" down 1

docker-up:
	docker compose up -d

docker-down:
	docker compose down

docker-logs:
	docker compose logs -f api

Deploy no Fly.io

# Instalar flyctl
curl -L https://fly.io/install.sh | sh

# Login e criação da app
fly auth login
fly launch --name taskapi --region gru

# Banco PostgreSQL gerenciado
fly postgres create --name taskapi-db --region gru
fly postgres attach taskapi-db

# Segredos
fly secrets set JWT_SECRET="$(openssl rand -hex 32)"

# Deploy
fly deploy

# Logs em tempo real
fly logs
# fly.toml — gerado pelo fly launch, ajuste conforme necessário
app = "taskapi"
primary_region = "gru"

[build]
  dockerfile = "Dockerfile"

[env]
  PORT = "8080"

[http_service]
  internal_port = 8080
  force_https = true

  [[http_service.checks]]
    grace_period = "10s"
    interval = "30s"
    method = "GET"
    path = "/saude"
    timeout = "5s"

[[vm]]
  memory = "256mb"
  cpu_kind = "shared"
  cpus = 1

Uma API completa junta as peças dos artigos anteriores: domínio com regras, migrações versionadas, repositório com consultas que filtram pelo dono, handler que traduz erros em códigos HTTP, composição no main, imagem Docker em dois estágios e deploy. O que separa um projeto de exemplo de um projeto que roda está nos detalhes medidos aqui: imports que sobram e impedem a compilação, um total de paginação que zera depois da última página, uma imagem de build mais velha que o go.mod, um Makefile indentado com espaços. Um go build ./..., um docker build e uma chamada real a cada rota, rodados antes do primeiro deploy, encontram todos eles.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um usuário com 3 tarefas pede GET /tarefas?pagina=3&limite=2, uma página além da última. A primeira versão do repositório respondia lista vazia e total: 0, e o front-end escondia a paginação, como se o usuário não tivesse tarefas. Por que o total vinha zerado?

Ver resposta

✓ Resposta: O total vinha de COUNT(*) OVER(), uma função de janela que acrescenta a contagem a cada linha do resultado. É uma forma eficiente de obter a página e o total numa consulta só, mas o valor só existe se houver ao menos uma linha: com o OFFSET além do fim, nenhuma linha volta, e o total fica com o valor inicial, zero. Num teste contra o PostgreSQL, a página 1 com limite 2 trouxe 2 itens e total 3, e a página 3 trouxe 0 itens e total 0. A versão do artigo detecta o caso, sem linhas e com página maior que 1, e faz uma contagem separada com os mesmos filtros; no mesmo teste, a página 3 passou a informar total 3. A alternativa é sempre contar numa consulta própria, mais simples de ler e com um custo a mais por requisição.

Exercício 2

O handler já garante pagina >= 1 antes de chamar o caso de uso. Ainda assim, a versão do artigo repete a verificação dentro do repositório. Isso é redundância desnecessária?

Ver resposta

✓ Resposta: O repositório é usado por quem estiver do outro lado da interface, e o handler HTTP é só um deles: um job de exportação, uma CLI ou um teste podem chamá-lo direto. Com Pagina: 0, o cálculo (f.Pagina-1)*f.Limite dá um deslocamento negativo, e num teste o PostgreSQL recusou a consulta com OFFSET must not be negative (SQLSTATE 2201X). O erro não vaza dados nem corrompe nada, mas transforma uma entrada trivial num 500. A verificação no repositório custa duas linhas e faz a função ser correta por si só, sem depender de quem a chama, o que é especialmente importante numa camada pensada para ser reutilizada. Depois da correção, a mesma chamada devolveu a primeira página normalmente.

Exercício 3

O repositório da primeira versão deste artigo importava "fmt" e "time" sem usá-los. Num editor com goimports, ninguém notaria; copiado para um editor simples, o projeto não compila. Qual é a mensagem, e por que o Go é tão rígido nisso?

Ver resposta

✓ Resposta: O compilador recusa o pacote com "fmt" imported and not used e, depois de removido o primeiro, com "time" imported and not used: num teste, os dois apareceram em sequência, porque o compilador para no primeiro erro de cada arquivo. A regra é deliberada. Um import sem uso pode parecer inofensivo, mas executa o init do pacote, aumenta o binário e o tempo de compilação, e esconde que o código mudou e o import ficou para trás. Ferramentas como o goimports e o gopls removem esses imports ao salvar, e por isso eles quase nunca chegam ao repositório. Em código copiado de um texto, como este, chegam: compilar todo exemplo antes de publicar evita o problema na origem.

Exercício 4

O go.mod do projeto, criado com uma versão recente do Go, declara go 1.27, e o Dockerfile da primeira versão usava FROM golang:1.22-alpine. O docker build falha logo no go mod download. Por quê?

Ver resposta

✓ Resposta: Desde o Go 1.21, a diretiva go do go.mod é um requisito mínimo: um toolchain mais antigo não compila o módulo. Fora do Docker, o Go baixaria sozinho a versão necessária, mas as imagens oficiais definem GOTOOLCHAIN=local, que proíbe esse download. Medido com o Go 1.27, um módulo que exige uma versão maior que a instalada, com GOTOOLCHAIN=local, falha com go.mod requires go >= 1.99 (running go 1.27.1; GOTOOLCHAIN=local); com a imagem de 1.22 e um módulo de 1.27, a mensagem é a mesma, com os números trocados. A correção é usar uma imagem com a mesma versão da diretiva, como golang:1.27-alpine, e manter as duas juntas: o CI pode conferir que a versão do FROM corresponde à do go.mod. A imagem final, alpine, também envelhece; a 3.19 da primeira versão já tinha saído do suporte.

Exercício 5

Quando o usuário 2 pede a tarefa 1, que pertence ao usuário 1, a API responde 404, e não 403. Num teste, BuscarPorID(1, 2) e Deletar(1, 2) devolveram tarefa não encontrada. Por que essa escolha, e onde ela é garantida?

Ver resposta

✓ Resposta: Responder 403 confirmaria que a tarefa 1 existe e pertence a outra pessoa, e um atacante poderia percorrer os IDs para mapear quantas tarefas existem e quais estão ativas. Com 404, a resposta é a mesma para "não existe" e "não é sua", e nada vaza. A garantia está na consulta, e não numa verificação depois: WHERE id = $1 AND usuario_id = $2 faz o banco nem devolver a linha de outro usuário, e o DELETE com a mesma condição não afeta nada, o que o RowsAffected() == 0 traduz em ErrTarefaNaoEncontrada. Buscar só pelo ID e conferir o dono no código também funcionaria, mas basta esquecer a conferência em uma das rotas para abrir um acesso indevido, a falha conhecida como IDOR. Com o filtro na consulta, esquecer é mais difícil, porque o repositório nem oferece um método sem o usuário.

Comentários

Mais em Golang

Interfaces: contratos implícitos e polimorfismo
Interfaces: contratos implícitos e polimorfismo

Interfaces em Go: implementação implícita, any, type assertion e type switch…

Go na nuvem: deploy no Railway, Fly.io e Google Cloud Run
Go na nuvem: deploy no Railway, Fly.io e Google Cloud Run

Deploy de uma API Go no Railway, no Fly.io e no Google Cloud Run: CLIs…

Métodos em Structs: receivers por valor e por ponteiro
Métodos em Structs: receivers por valor e por ponteiro

Métodos em Go: receiver por valor e por ponteiro, a conversão automática para…