Artigo 48 — APIs RESTful completas: projeto do zero ao deploy
Curso: Dominando Go em 1 Ano Prof. Ricardo Matos Módulo 8 — Arquitetura e Padrões
Do conceito ao sistema em produção
Os artigos anteriores 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
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 TYPE prioridade_tarefa AS ENUM ('baixa', 'media', 'alta', 'urgente');
CREATE TABLE tarefas (
id BIGSERIAL PRIMARY KEY,
usuario_id BIGINT NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
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;
Repositório
// internal/infra/postgres/tarefa_repo.go
package postgres
import (
"context"
"errors"
"fmt"
"time"
"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) {
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)
}
return tarefas, total, rows.Err()
}
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
FROM golang:1.22-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.19
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:16-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
.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
Resumo do que foi coberto
Este artigo construiu uma API RESTful completa: estrutura de projeto profissional, domínio com regras de negócio, migrações SQL versionadas, repositório PostgreSQL com pgx, handler HTTP com tradução de erros de domínio, composição no main com todos os componentes, Dockerfile multistage para imagem mínima, docker-compose para desenvolvimento local e deploy no Fly.io. Com este artigo o Módulo 8 está completo — o próximo módulo aborda compilação, Docker e cloud.
Referências e leituras complementares
-
Fly.io — Go deployment — Guia oficial de deploy de aplicações Go. https://fly.io/docs/languages-and-frameworks/golang/
-
golang-migrate — Ferramenta de migrações de banco de dados. https://github.com/golang-migrate/migrate
-
Chi router — Documentação e exemplos do Chi. https://github.com/go-chi/chi
-
pgx v5 — Driver PostgreSQL para Go. https://github.com/jackc/pgx
-
Air — Live reload — Hot reload para desenvolvimento Go. https://github.com/air-verse/air
-
Docker multistage builds — Guia oficial de builds multistage. https://docs.docker.com/build/building/multi-stage/
Próximo artigo: Artigo 49 — Compilação cruzada, binários estáticos e Docker com Go**
you asked
Sim