README
¶
Preá Framework
Framework web modular para Go, inspirado no Spring e no Angular. Ele traz módulos com injeção de dependência por tag, handlers HTTP sobre o gin, um repositório base para Postgres com queries no estilo PDO, middlewares em três níveis e uma CLI que gera o código de cada módulo.
Status: pré-alfa (
v0.0.1). A API ainda muda. Os gaps conhecidos e os próximos passos estão no ROADMAP.md.
Sumário
Requisitos
- Go 1.26+
- PostgreSQL, se a app usar banco (com
gen_random_uuid(), nativo a partir do 13)
Começando
# 1. CLI: instala com o go install e põe no PATH (zsh, bash, fish ou sh)
curl -fsSL https://raw.githubusercontent.com/prea-framework/prea/main/install.sh | sh
# uma versão: ... | PREA_VERSION=v0.0.1 sh; num clone: go install ./cmd/prea
# 2. Um projeto novo
prea new loja --with-db # sem --with-db: só o preá health, sem banco
cd loja
cp .env.example .env # preencha DATABASE_URL e TEST_DATABASE_URL
# 3. Rodar com hot reload: o profile dev gera e aplica as migrations a partir dos models
prea dev
Para testar:
curl localhost:8000/health
curl -X POST localhost:8000/item -H 'Content-Type: application/json' -d '{"name": "caneta"}'
prea tests # os testes, com o profile tests
prea build # o binário em bin/loja; leve config/ e migrations/ junto no deploy
Estrutura do projeto
Um projeto criado pelo prea new:
loja/
├── main.go # prea.Run(prea.Config{Modules: preas.Modules, ...})
├── config/ # application.yaml e os profiles dev, prod e tests
├── migrations/ # 0.0.1_models.sql...
├── middleware/ # middlewares da app (prea g m)
├── preas/
│ ├── preas.go # Modules, Middlewares, Security e Logging: o que a app declara
│ ├── health/ # GET /health, com o teste ao lado (health_test.go)
│ └── item/ # com --with-db: o CRUD gerado pelo prea g p
└── tests/ # os testes de ponta a ponta, da app inteira
// main.go: só liga as variáveis do preas/preas.go ao prea.Run
func main() {
prea.Run(prea.Config{
Modules: preas.Modules,
Middlewares: preas.Middlewares,
Security: preas.Security,
Logging: preas.Logging,
})
}
O prea.Run, em ordem:
- carrega
config/com o profile ativo; - confere as chaves obrigatórias e configura os logs;
- abre o banco de
database.url, se houver; - com
PREA_MIGRATE(oprea migrate), roda o comando e sai; - aplica o segredo JWT, se houver;
- monta os módulos, gera e aplica as migrations, confere o schema e serve até SIGINT/SIGTERM.
Este repositório:
prea/
├── prea.go # módulo da raiz: prea.Run e prea.Test, que ligam os outros
├── go.work # liga os módulos no desenvolvimento
├── cmd/prea/ # módulo da CLI (instalável; não depende do framework)
│ └── templates/ # o projeto do prea new (new/) e os preás do prea g p (prea/)
├── core/ # módulo: Module, App, Router, Middleware, Security, respostas e `core.NewError`
├── di/ # módulo: container de injeção
├── database/ # módulo: BaseRepository, Prepare, schema e migrations
├── security/ # módulo: JWT e hash de senha (argon2id)
├── logger/ # módulo: logs por nível (slog)
└── config/ # módulo: carrega a config (koanf) e os profiles
Direção das dependências: CLI → nada, app → framework, framework → nada da app.
Módulos Go
Cada pasta do framework é um módulo, instalado e versionado à parte. O da raiz (prea.Run) liga todos e é o que um projeto do prea new usa; quem monta o core.App à mão e usa só o core não leva o driver do banco nem o JWT:
| Módulo | Depende de (framework) | Dependências externas |
|---|---|---|
github.com/prea-framework/prea |
core, database, security, config, logger, di |
gin, pgx |
.../di |
— | — |
.../logger |
— | — |
.../config |
— | koanf, mapstructure, godotenv |
.../security |
— | golang-jwt, x/crypto |
.../database |
logger (e di nos testes) |
pgx, goose |
.../core |
di, config, logger |
gin |
.../cmd/prea |
— | urfave/cli |
go get github.com/prea-framework/prea # tudo, com o prea.Run
go get github.com/prea-framework/prea/core # só HTTP e DI
- O
corenão conhece o banco nem a segurança. Os erros dodatabasee dosecuritydizem o próprio status (HTTPStatus(), veja Erros e respostas), e o banco entra na subida pelo ganchocore.App.Setup:core.App{..., Globals: []any{db}, Setup: database.Setup(db, opts)} - Versões: uma tag por módulo, com o prefixo da pasta (
core/v0.0.1,cmd/prea/v0.0.1); a do módulo da raiz não tem prefixo (v0.0.2). Todos os módulos ficam na mesma major; as minors e os patches andam separados, conforme cada um muda. A versão atual é a pré-alfav0.0.1. - No repositório: o
go.workcommitado liga os módulos pelo disco, então um clone compila semreplace. Um módulo que passa a exigir uma versão nova de um irmão só funciona fora do workspace depois que a tag dessa versão é publicada. Na raiz, o./...cobre só o módulo da raiz, porque os outros são módulos aninhados; os comandos usam o caminho:go test github.com/prea-framework/prea/.... - O CI confere que cada módulo importa exatamente os módulos do framework que o seu
go.modexige. No workspace, um import semrequirecompila, e é essa conferência que impede ocorede voltar a importar odatabase.
Conceitos
Módulos (preás)
Cada funcionalidade é um módulo em preas/<nome>/:
user/
├── module.go # declara os componentes, exports, imports e middlewares
├── handler.go # HTTP: rotas, leitura do corpo da requisição, respostas
├── service.go # regras de negócio
├── repository.go # acesso ao banco
├── model.go # entidade (mapeia a tabela)
└── dtos/ # um arquivo por DTO
var Module = &core.Module{
Name: "user",
Providers: []any{
Repository{},
di.Export(Service{}), // visível para módulos que importarem este
Handler{},
},
}
Um módulo que usa outro o declara em Imports:
var Module = &core.Module{
Name: "auth",
Imports: []*core.Module{user.Module},
Providers: []any{Service{}, Handler{}},
}
Os módulos são ligados à aplicação na var Modules do preas/preas.go. O prea g p registra o módulo novo ali automaticamente.
Injeção de dependência
Campos com a tag inject:"" são preenchidos na subida:
type Service struct {
Repo *Repository `inject:""`
Users *user.Service `inject:""` // vem do export de um módulo importado
}
- Ordem de busca: os providers do próprio módulo, depois os exports dos módulos importados, depois os globais (
core.App.Globals, por exemplo o pool do banco). - Interfaces: um campo do tipo interface é injetado se existir exatamente um provider que a implementa.
- Mais de um candidato para o mesmo tipo dá erro na subida, e o erro pede um nome. Nunca é escolhido um em silêncio.
- Structs embutidas também são injetadas; é assim que o
BaseRepositoryrecebe a conexão. - Providers são declarados como valor (
Repository{}), e não como ponteiro. O módulo é só uma declaração: cada container (cadacore.App) cria as próprias instâncias a partir dela e nunca altera a declaração.- Dois
Appno mesmo processo, como testes com bancos diferentes ou uma API e um admin, não compartilham nada. - Vários containers podem carregar o mesmo módulo ao mesmo tempo sem data race.
- Declarar com
new(Repository)dá erro na subida:provider *user.Repository must be declared as a value, user.Repository{}.
- Dois
- Campo preenchido na declaração é mantido, o que serve para mocks e configuração:
Service{Repo: mockRepo}eroutes{path: "/x"}. A cópia é rasa, então ponteiros preenchidos ali são compartilhados por quem usar a mesma declaração. - Os componentes são singletons por container: dentro de um
App, todos que injetam*Servicerecebem a mesma instância. Os campos injetados precisam ser exportados. - Instâncias prontas, como o pool do banco, entram em
core.App.Globals, e essas sim são compartilhadas. - Concorrência: um mesmo componente atende todas as requisições ao mesmo tempo, cada uma em sua goroutine. Não guarde estado de requisição em campos da struct; use variáveis locais ou o
gin.Context. Estado compartilhado mutável precisa desync.Mutexouatomic. - Erros aparecem na subida, com o caminho do campo, por exemplo
di: module auth: Service.Users: no provider for *user.Service. Isso vale para dependência faltando, provider duplicado, interface ambígua e ciclo de imports.
Use sempre inject:"". A forma curta inject sem valor não é uma tag válida em Go (o go vet acusa) e dá erro na subida.
Componentes nomeados. O nome é opcional, como um token do Angular. Ele serve para quando há mais de um componente do mesmo tipo:
// num core.App montado à mão (o prea.Run abre só o pool de database.url)
Globals: []any{mainDB, di.Named("analytics", analyticsDB)}
// em um módulo; Named e Export podem ser combinados em qualquer ordem
Providers: []any{di.Named("stripe", Gateway{}), di.Export(di.Named("paypal", Gateway{}))}
type Reports struct {
DB *pgxpool.Pool `inject:""` // o sem nome (mainDB)
Analytics *pgxpool.Pool `inject:"analytics"` // o nomeado
}
inject:"nome"procura só os componentes com esse nome.inject:""prefere o componente sem nome. Se só existir um, mesmo nomeado, ele é usado.
Ciclo de vida. Um componente pode ter, opcionalmente, hooks de subida e de desligamento:
type Mailer struct {
Config *Config `inject:""`
client *smtp.Client
}
// roda na subida, depois da injeção e antes de o servidor aceitar requisições
func (m *Mailer) Init(ctx context.Context) error {
c, err := smtp.Dial(m.Config.SMTPAddr)
if err != nil {
return err // a app não sobe
}
m.client = c
return nil
}
// roda no desligamento, depois que as requisições em andamento terminam
func (m *Mailer) Close(ctx context.Context) error { return m.client.Quit() }
Initfaz o papel de construtor: lê config, valida, abre conexões. Se der erro, a app não sobe, e os componentes que já tinham iniciado são fechados.- Ordem: o
Initsegue as dependências. Se A injeta B, oB.Initroda antes doA.Init, inclusive entre módulos. OCloseroda na ordem inversa. Closepode serClose(ctx) errorouClose() error(io.Closer). Ele roda no desligamento gracioso, antes de o pool do banco fechar.- Globals não passam pelo ciclo de vida: quem cria, como o
maincom o pool, é quem fecha. - Em testes:
engine, closer, err := app.Build(ctx)monta a app com oInite devolve ocloser, que roda oClose.
Handler, Service, Repository, Model e DTOs
Handler. Só cuida de HTTP. Todo componente que implementa core.Router é montado automaticamente:
type Handler struct {
Service *Service `inject:""`
Auth *middleware.Auth `inject:""`
}
var _ core.Router = (*Handler)(nil) // falha na compilação se Register estiver errado
func (h *Handler) Register(r gin.IRouter) {
g := r.Group("/users")
g.POST("", h.create)
g.GET("", h.Auth.Handle, h.list)
}
Service. Guarda as regras de negócio e devolve erros com status (veja Erros e respostas).
Model. A struct da tabela, com o método Table(). As tags descrevem o schema (veja Model e schema):
type User struct {
ID string `json:"id" pk:"" generated:"uuid"`
Email string `json:"email" unique:""`
LastLogin *time.Time `json:"lastlogin" db:"lastlogin"`
}
func (User) Table() string { return "users" }
DTOs. Ficam em dtos/, um arquivo por DTO. Os campos viram colunas no Create/Update pela mesma regra de nomes do model.
Banco de dados
Model e schema
O model é a fonte da verdade do schema. Cada opção é uma tag própria, como o inject:"":
type Player struct {
ID string `pk:"" generated:"uuid"`
Email string `size:"255" unique:""`
First string `unique:"full_name"` // unique composto: mesmo nome de grupo
Last string `unique:"full_name"`
TeamID *int64 `fk:"teams" ondelete:"cascade" index:""`
Profile int16 `default:"1"`
Settings []byte `type:"jsonb"`
Version int64 `version:""`
LastLogin *time.Time `db:"lastlogin"`
Notes string `db:"-"`
}
| Tag | O que faz |
|---|---|
db:"nome" / db:"-" |
nome da coluna (o padrão é o campo em snake_case: TeamID → team_id), ou ignora o campo |
pk:"" |
chave primária; em mais de um campo, é composta. Sem nenhum pk, a coluna id é a PK |
generated:"auto_increment|uuid|auto" |
só na PK. auto_increment: inteiro gerado pelo banco (generated by default as identity). uuid: uuid default gen_random_uuid(). auto: escolhe pelo tipo Go (inteiro ou string) |
size:"n" |
varchar(n); sem ele, uma string vira text |
type:"x" |
tipo explícito, no nome interno do Postgres (jsonb, int4, numeric) |
default:"lit" |
valor padrão literal, conferido contra o tipo do campo: default:"1", default:"true", default:"pt-BR". Funções do banco (now()) não entram por enquanto |
unique:"" / unique:"grupo" |
unique da coluna, ou composto com os campos do mesmo grupo |
index:"" / index:"grupo" |
índice, com a mesma regra de grupo |
fk:"tabela" + ondelete:"cascade|restrict|set null" |
chave estrangeira para a PK do model daquela tabela |
version:"" |
lock otimista, como o @Version do JPA (veja Save) |
- Nulidade vem do tipo Go: um ponteiro aceita
null; um valor énot null. - Tipos:
string→text,int64/int→int8,int32→int4,int16/int8→int2,bool→bool,float64→float8,float32→float4,time.Time→timestamptz,[]byte→bytea. Qualquer outro tipo precisa detype. - Erros na subida: tag com valor inválido,
generatedfora da PK,sizefora de string,defaultque não converte,versionque não é inteiro, model sem PK,fkpara uma tabela sem model e ciclo de FKs. Todos de uma vez, com o caminho do campo:database: user.User.Email: size:"x" needs a string field and a positive number. - Limitação: um typo no nome da tag (
uniqe:"") não é detectado, porque o Go não diferencia uma tag desconhecida das de outras libs (json,binding).
Schema na subida. A chave database.schema funciona como o ddl-auto do Hibernate. Os models são os dos repositórios do container (todo componente que embute um BaseRepository[T]), sem registro à parte:
| Modo | O que faz |
|---|---|
none (padrão) |
só confere as tags |
validate |
compara as tabelas com os models. Tabela ou coluna faltando, tipo diferente e nulidade diferente impedem a subida, todas listadas de uma vez |
update |
cria o que falta: schemas, tabelas, colunas e índices. Nunca apaga nem altera uma coluna |
create |
apaga as tabelas dos models e as cria de novo |
create-drop |
create, e apaga as tabelas no desligamento; bom para testes |
O schema é aplicado numa transação, depois da injeção e antes dos Init, então um Init já encontra as tabelas. Para manter histórico e revisar as mudanças, use as migrations: num projeto do prea new --with-db, a base só valida (schema: validate) e o dev gera as migrations a partir dos models.
Migrations
Uma pasta de arquivos SQL versionados, rodados pelo goose. O que está no arquivo roda, como está escrito, inclusive funções com $$:
migrations/
├── 0.0.1_models.sql # gerada a partir dos models
├── 0.0.2_add_active.sql # prea migrate create add_active
└── 0.1.0_backfill.sql # a versão é sua: pode pular
alter table users add column active bool not null default true;
-- prea:down
alter table users drop column active;
- Nome:
<versão>_<nome>.sql. A versão é semver (0.0.1, o padrão) ou um timestamp (20261013120000, comversion: timestamp), sem misturar os dois na mesma pasta. Ocreatesobe o patch:0.0.1,0.0.2... -- prea:down(opcional): o que vem abaixo é o que odownroda. Sem essa linha, odownrecusa a migration em vez de desfazer pela metade.-- prea:no-transaction(opcional, na primeira linha): roda fora de transação, paracreate index concurrently. Sem ela, cada migration roda numa transação: se falha, nada dela fica, e as seguintes não rodam.- Controle: o que já rodou fica na tabela
prea_migrations, criada antes da primeira migration. Um advisory lock impede duas instâncias de migrar ao mesmo tempo. Uma migration pendente mais antiga que a última aplicada (depois de um merge) também roda.
database:
schema: validate
migrations:
dir: migrations # padrão
version: semver # semver (padrão) | timestamp
on_start: false # aplica as pendentes na subida
from_models: false # gera, na subida, uma migration com o diff entre os models e o banco
Na subida, a ordem é: from_models gera → on_start aplica → schema confere. Quem faz isso é o database.Setup(pool, opts), ligado ao core.App.Setup no main: ele roda depois de os módulos carregarem e antes de qualquer Init. No profile dev da app de exemplo, os dois ficam ligados: mudou um model, reiniciou, e a migration é gerada e aplicada.
-- migrations/0.0.2_models.sql, gerada depois de adicionar Color *string ao model
-- Generated by prea from the models: review it before committing.
alter table "notes" add column "color" text;
-- prea:down
alter table "notes" drop column "color";
- O diff compara os models com o banco do profile: tabela, coluna e índice faltando, tipo e nulidade. Uma coluna que existe no banco e não no model sai comentada, porque apagá-la perde dados; quem revisa decide.
- Com migrations pendentes, nada é gerado: elas podem já ter as mudanças. Aplique-as antes (
on_startouprea migrate up). from_modelscomschema: update/createdá erro na subida: um aplicaria direto o que o outro registra.- Em produção, deixe o
from_modelsdesligado e aplique as migrations no deploy (prea migrate up) ou comon_start.
Repositório
O database.BaseRepository[T] traz o CRUD pronto:
| Método | O que faz |
|---|---|
GetAll(ctx) |
todas as linhas, ordenadas pela PK |
GetByID(ctx, id) |
uma linha ou ErrNotFound. Com PK composta, o id é um database.Args com todas as colunas da chave (vale para Update e Delete) |
Where(ctx, database.Args{"owner_id": 1}) |
as linhas que batem com o filtro (nil vira IS NULL) |
First(ctx, args) |
a primeira linha que bate com o filtro |
Create(ctx, dto) |
insere todos os campos do DTO e devolve a linha criada |
Update(ctx, id, dto) |
patch: atualiza só os campos não zerados; para gravar um zero, use ponteiro no DTO (nil = não mexe) ou database.Args. Incrementa o version |
Save(ctx, entidade) |
como o save do JPA: grava todas as colunas, zeros inclusive. Com PK zerada, insere; com PK generated, atualiza (ErrNotFound se não existe); com PK natural (sem generated, composta inclusive), insere ou atualiza (upsert). Com version, só grava se a versão ainda é a da entidade, e a incrementa; senão, ErrConflict |
Count(ctx, args) |
quantas linhas batem com o filtro (nil conta todas) |
Exists(ctx, args) |
se alguma linha bate com o filtro |
Delete(ctx, id) |
apaga a linha, ou devolve ErrNotFound |
Atualizar no estilo JPA: carregue, altere e salve. Use uma transação quando a leitura e a escrita precisam ser atômicas:
err := s.Transaction(ctx, func(ctx context.Context) error {
u, err := s.Repo.GetByID(ctx, id)
if err != nil {
return err
}
u.Active = false // um zero que o Update ignoraria
_, err = s.Repo.Save(ctx, u)
return err
})
No insert, o Save grava todos os campos, então só a PK recebe o default do banco. Para usar os defaults das outras colunas, use o Create com um DTO.
Queries próprias, no estilo PDO, com parâmetros nomeados @nome:
type Repository struct {
database.BaseRepository[User]
}
func (r *Repository) FindByUsername(ctx context.Context, username string) (User, error) {
return r.Prepare("select * from users where username = @username").
Bind("username", username).
One(ctx)
}
All(ctx),One(ctx)eExec(ctx)correspondem a fetchAll, fetch e rowCount.- Escolher os campos: os métodos prontos usam
select *; para trazer só algumas colunas, escreva-as noPrepare.database.Prepare[T](conn, sql)mapeia o resultado para qualquer tipo: um DTO com parte das colunas (Prepare[Summary](r.DB, "select id, name from users")), ou um valor simples comoPrepare[int64](r.DB, "select max(profile) from users"). connpode ser o pool ou uma transação (pgx.Tx). Com o pool, a transação doctx(veja abaixo) tem prioridade.
Transações, no estilo @Transactional: embuta database.Transactional no componente e envolva os métodos que precisam ser atômicos:
type Service struct {
database.Transactional // injeta o pool, como um inject:""
Users *Repository `inject:""`
Audits *audit.Service `inject:""`
}
func (s *Service) Register(ctx context.Context, req dtos.CreateRequest) (User, error) {
var u User
err := s.Transaction(ctx, func(ctx context.Context) error {
var err error
if u, err = s.Users.Create(ctx, req); err != nil {
return err
}
return s.Audits.Log(ctx, "user created", u.ID)
})
return u, err
}
- A transação viaja no
ctx. Tudo que roda com octxrecebido pela função usa a transação: oBaseRepository, or.Preparee os services de outros preás. Os repositórios não mudam. - Commit quando a função devolve
nil. Rollback em erro ou panic, e o panic continua subindo. - Aninhada, uma transação dentro de outra abre um savepoint: o erro desfaz só o trabalho interno, e a externa decide se segue ou devolve o erro.
- Cuidados:
- use o
ctxda função, não o de fora, senão a query roda fora da transação; - uma transação é uma conexão, então não use o
ctxdela em várias goroutines ao mesmo tempo; - acesso direto ao pool (
r.DB.Query) fica fora da transação: user.Prepare.
- use o
Nomes das colunas: o campo vira snake_case (OwnerID → owner_id). A tag db:"nome" sobrescreve e db:"-" ignora o campo. Nomes de tabelas e colunas são sempre escapados.
Erros do Postgres viram erros do framework:
| Erro do Postgres | Erro do framework | HTTP |
|---|---|---|
| unique violation | ErrConflict |
409 |
| valor malformado (ex.: uuid inválido), not null, foreign key, check | ErrInvalidInput |
400 |
| nenhuma linha | ErrNotFound |
404 |
Middlewares
Existem dois tipos de middleware:
- Componente (padrão do
prea g m <Nome>): uma struct com camposinjecte um métodoHandle(c *gin.Context). O container gerencia como qualquer componente: cria, injeta e chamaInit/Close. Use quando o middleware precisa de um service, do banco ou de config. - Função (
prea g m <Nome> --func): umcore.Middleware, que é o mesmo quegin.HandlerFunc, sem injeção.
// componente: middleware/auth.go
type Auth struct {
Sessions Sessions `inject:""` // interface; user.Service a implementa
}
func (a *Auth) Handle(c *gin.Context) { ... }
Onde cada um pode ser usado:
| Nível | Função | Componente |
|---|---|---|
global (preas.Middlewares e Security.Middlewares) |
core.RequestLogger |
middleware.RateLimit{} |
módulo (Module.Middlewares, vale para todas as rotas do módulo) |
gin.BasicAuth(contas) |
middleware.Auth{} |
| rota | g.GET("", middleware.Owner, h.list) |
injetado no handler: g.GET("", h.Auth.Handle, h.list) |
var Module = &core.Module{
Name: "admin",
Providers: []any{Handler{}},
Middlewares: []any{core.Middleware(audit), middleware.Auth{}}, // funções e componentes, na ordem
}
- Por módulo, o componente é criado automaticamente: não precisa repetir em
Providers. - Global, o componente fica num módulo implícito
appque importa todos os módulos: injeta osGlobalse os exports (di.Export) de qualquer módulo, e passa porInit/Close. Se aparece nas duas listas globais, é a mesma instância. - Por rota, declare
middleware.Auth{}emProviderse injete no handler (Auth *middleware.Auth `inject:""`). - Nos dois ao mesmo tempo, é a mesma instância.
- Erros na subida, não em produção: declarar
&middleware.Auth{}ou uma struct semHandle. - Cuidado com o atalho
(&middleware.Auth{}).Handle. Ele compila, mas cria umAuthfora do container e sem injeção, e dá panic na primeira requisição. Passe sempre o valormiddleware.Auth{}.
Para depender de outro preá sem ciclo de imports, use uma interface: o auth declara um Sessions, e o user.Service o implementa.
A ordem de execução é segurança → global → módulo → rota → handler. Chame c.Next() para continuar ou c.Abort() para parar a cadeia.
Logs
O pacote logger tem quatro níveis, em ordem: debug < info < success < error.
logger.Debug("cache miss", "key", k)
logger.Info("enviando e-mail", "to", u.Email)
logger.Success("usuário criado", "id", u.ID)
logger.Error("falha no pagamento", "error", err)
time=2026-10-10T03:09:47.989-03:00 level=SUCCESS msg=request method=POST path=/users status=201 latency=18.6ms ip=::1
- Argumentos são pares chave/valor. A saída é estruturada (
log/slog), em texto ou JSON. logging.level(veja Configuração) define o nível mínimo, e o padrão éinfo. Comdebug, aparece também a saída do gin (rotas registradas, avisos).logging.format: jsoné a opção para produção ou para coletores de log; o profileprodjá usa.logger.Configure(level, format)aplica a config na subida; antes disso, o logger usainfoem texto.logger.Logé o*slog.Loggerpor trás e também o default doslog. Assim, olog.Printfe oslog.Infode qualquer lib caem no mesmo lugar. Para um logger próprio, como em testes ou outro destino, uselogger.New(w, level, json).core.RequestLoggergera uma linha por requisição: success abaixo de 400, info para 4xx e error para 5xx. Numa linha de 500 vem junto o erro real que foi passado aocore.Error; o cliente só vê "internal server error".core.Recoverytransforma um panic em 500 e loga o erro com a stack trace.
Erros e respostas
Erros de HTTP usam as constantes do net/http: o service ou o handler devolve um core.NewError(status, mensagem), e o handler chama core.Error(c, err), que responde com esse status e a mensagem:
if u.ID != claims.Payload.ID {
return core.NewError(http.StatusForbidden, "not the owner")
}
O core.Error responde com o status do primeiro erro da cadeia que tem o método HTTPStatus() int, e com a mensagem desse erro. Assim os pacotes do framework declaram os próprios erros sem que o core os importe:
| Erro | HTTP |
|---|---|
*core.HTTPError (core.NewError) |
o status do erro |
database.ErrInvalidInput |
400 |
database.ErrNotFound |
404 |
database.ErrConflict |
409 |
security.ErrInvalidToken |
401 |
o seu, com HTTPStatus() int |
o que o método devolver |
| qualquer outro | 500, com mensagem genérica |
O detalhe embrulhado em volta (fmt.Errorf("%w: ...", database.ErrInvalidInput), a mensagem do Postgres) não chega ao cliente, que recebe só "invalid input". O erro inteiro vai para o log da requisição.
Os erros chegam ao cliente como {"text": "...", "code": 2}.
Respostas. Todas usam o mesmo envelope {"text", "code", "data"}, montado com qualquer combinação de partes. Go não tem overload de métodos; o equivalente aqui é uma função que aceita partes variádicas:
core.Reply(c, 200, core.Text("salvo")) // {"text":"salvo","code":1}
core.Reply(c, 200, core.Code(core.WarningCode)) // {"code":2}
core.Reply(c, 200, core.Text("salvo"), core.Code(core.WarningCode))
core.Reply(c, 201, core.Text("criado"), core.Data(user)) // {"text":"criado","code":1,"data":{...}}
core.Reply(c, 200, core.Data(users)) // {"code":1,"data":[...]}
core.Response(c, 200, qualquerCoisa) // formato próprio, sem envelope
Sem Code, ele vem do status HTTP: abaixo de 400 é SuccessCode (1), abaixo de 500 é WarningCode (2) e o resto é DangerCode (3). O JSON sai indentado só em modo debug.
Servidor
O servidor vem da config. server.port e server.mode (debug, release ou test) são obrigatórias; os timeouts têm padrão, e o yaml só sobrescreve o que define:
server:
port: 8000
mode: debug
read_header_timeout: 5s # padrão 5s (proteção contra slowloris)
read_timeout: 30s # padrão 30s, corpo incluído
write_timeout: 30s # padrão 30s; aumente para streaming e downloads longos
idle_timeout: 120s # padrão 120s, conexões keep-alive
shutdown_timeout: 15s # padrão 15s, tempo para as requisições em andamento terminarem
max_header_bytes: 1048576 # padrão 1 MB
trusted_proxies: [] # padrão nenhum; o Security.TrustedProxies do código tem precedência
Subida e desligamento:
- Na subida, o banco recebe um
Ping, então um DSN errado falha na hora, e não na primeira requisição. - Em SIGINT/SIGTERM, o servidor para de aceitar conexões e espera as requisições em andamento por até
ShutdownTimeout. - Só depois o pool do banco é fechado. Nenhuma query em andamento perde a conexão.
- Se o tempo acaba, as conexões são fechadas e o contexto das requisições é cancelado. Por isso as queries devem usar
c.Request.Context(). - Um segundo Ctrl+C encerra na hora.
O tamanho do pool de conexões vai no próprio DSN: ?pool_max_conns=20 (o padrão é o maior entre 4 e o número de CPUs).
Segurança HTTP
A configuração fica na var Security do preas/preas.go e roda antes de qualquer middleware. As origens do CORS ficam aqui, no código, e não no profile:
var Security = core.Security{
CORS: &core.CORS{
AllowOrigins: []string{"http://localhost:3000"}, // "*" libera qualquer origem
// AllowMethods, AllowHeaders, ExposeHeaders, AllowCredentials, MaxAge
},
CSRF: &core.CSRF{
// TrustedOrigins: []string{"https://admin.exemplo.com"},
// Bypass: []string{"POST /webhooks/"},
},
Headers: core.DefaultHeaders, // nosniff, X-Frame-Options, CSP, HSTS...
// TrustedProxies: []string{"10.0.0.0/8"},
}
| Campo | O que faz | nil |
|---|---|---|
CORS |
responde o preflight (OPTIONS) e coloca os headers Access-Control-* nas origens permitidas |
sem CORS: o navegador bloqueia chamadas de outras origens |
CSRF |
bloqueia com 403 requisições não seguras (POST, PATCH...) vindas de outra origem pelo navegador, usando o net/http.CrossOriginProtection; as origens do CORS já são confiáveis; curl e chamadas servidor a servidor passam |
sem proteção |
Headers |
headers colocados em toda resposta; use maps.Clone(core.DefaultHeaders) para ajustar |
nenhum |
TrustedProxies |
proxies cujo X-Forwarded-For é aceito no c.ClientIP() |
nenhum proxy é confiável |
AllowCredentials com origem "*" é recusado na subida.
Configurações próprias. Para casos que o framework não cobre:
var Security = core.Security{
// regra própria de origem (subdomínios, consulta ao banco...); o CSRF também confia nela
CORS: &core.CORS{AllowOriginFunc: func(o string) bool { return strings.HasSuffix(o, ".exemplo.com") }},
// resposta própria quando o CSRF bloqueia (precisa chamar c.Abort)
CSRF: &core.CSRF{Deny: func(c *gin.Context) { c.AbortWithStatusJSON(403, gin.H{"erro": "origem"}) }},
// suas proteções: rodam depois de Headers, CORS e CSRF e antes dos middlewares globais
Middlewares: []any{middleware.RateLimit{}, middleware.CSRFToken}, // componentes e funções
}
- Peças avulsas:
core.Headers(map),(&core.CORS{...}).Middleware()e(&core.CSRF{...}).Middleware()funcionam por módulo ou por rota, por exemplo um CORS diferente só em/public. - Desligar uma peça: deixe o campo
nile coloque a sua versão emMiddlewares, ou use outra lib, como ogin-contrib/cors.
Autenticação
O pacote security traz as peças:
- JWT:
security.Jwt[M]cria e valida tokens HS256 (só esse algoritmo é aceito), com o payloadMnas claims. A chave vem desecurity.jwt.secrete precisa ter 32 bytes ou mais. Sem ela, a app sobe normalmente, e só criar ou validar um token dá erro. - Senhas:
security.HashPasswordesecurity.ComparePassword, com argon2id. - Erros: um token inválido responde 401 pelo
core.Error.
O middleware de autenticação, as sessões revogáveis e os papéis entram com a lib de segurança (veja o ROADMAP).
CLI
| Comando | O que faz |
|---|---|
prea new <nome> [--module] [--with-db] |
cria um projeto (veja Estrutura do projeto). --module é o caminho do módulo Go, que vai no go.mod e nos imports (padrão: o nome); --with-db liga o banco nos profiles e gera um preá de CRUD; --local <dir> usa um clone do framework em vez de baixá-lo |
prea dev [--profile] |
compila e sobe o projeto (profile dev por padrão) e o reinicia quando um .go, o config/ ou o .env muda. Um erro de compilação mantém a versão que está rodando |
prea run [--profile] |
compila e sobe o projeto, repassando os sinais para o desligamento gracioso |
prea build [flags] |
gera o binário em bin/<nome>; as flags vão para o go build (-o, -trimpath...), e o GOOS/GOARCH vêm do ambiente |
prea tests [flags] [pacotes] |
go test com o profile tests; sem pacotes, ./... |
prea g p <Nome> |
cria preas/<nome>/ (module, handler, service, repository, model e dtos) e registra o módulo em preas/preas.go |
prea g m <Nome> [--func] |
cria middleware/<nome>.go: um componente (com injeção), ou uma função com --func |
prea migrate create <nome> |
cria migrations/<próxima versão>_<nome>.sql, para escrever à mão |
prea migrate up [versão] |
aplica as pendentes, ou até a versão |
prea migrate down [n] |
desfaz as últimas n aplicadas (padrão 1) |
prea migrate status |
lista as migrations: aplicadas (com a data) ou pendentes |
prea migrate validate |
confere os arquivos sem tocar no banco; para CI |
Os prea migrate aceitam --profile (prea migrate --profile dev up). Eles compilam o projeto e o rodam em modo de inspeção (PREA_MIGRATE, a database.MigrateEnv): o prea.Run vê a variável e, em vez de subir o servidor, roda o database.Command com a própria config e o próprio banco. Assim a CLI não precisa saber onde estão a config e a conexão. Isso vem antes da checagem do segredo JWT, então uma migration não o exige.
O g é atalho de generate, o p de prea e o m de middleware. Rode a CLI na raiz do projeto: ela lê o go.mod para descobrir o caminho dos imports.
Nomes: letras, em camel case ou separadas por _. OrderItem e order_item são a mesma coisa; números não são aceitos (order123). Cada parte do código recebe a forma que a convenção pede:
prea g p order_item |
|
|---|---|
| pacote e pasta | orderitem (pacote Go não usa _) |
| tipos | OrderItem |
| tabela | order_item (que você cria no banco) |
| rotas | /order-item (GET, GET /:id, POST, PATCH /:id, DELETE /:id) |
No middleware, o arquivo segue a tabela: prea g m rate_limit cria middleware/rate_limit.go com o tipo RateLimit.
Antes de gerar, a CLI confere tudo; se algo falha, o projeto não é alterado:
- o nome não pode ser uma palavra-chave do Go (
type); - o pacote não pode ter o nome de um import do
preas.go(core,time); - o preá ou o arquivo do middleware não pode existir;
- o middleware não pode ter o nome de algo já declarado no pacote (
Owner); - o
preas.goprecisa ter o bloco de imports entre parênteses e ovar Modules = []*core.Module{...}.
Configuração
No estilo do Spring: application.yaml é a base, e application-<profile>.yaml a completa para o profile ativo. Os arquivos são da aplicação e ficam em config/, na raiz do projeto. O carregamento usa o koanf.
# config/application.yaml
server:
port: 8000
database:
url: ${DATABASE_URL} # vem do ambiente ou do .env
security:
jwt:
secret: ${JWT_SECRET}
- Profile ativo: um só, vindo de
PREA_PROFILE=devou deprea run --profile dev. Sem profile, só a base é carregada; um arquivo que não existe é ignorado. - Ambiente: só entra onde o yaml o referencia, com
${VAR}. O.envda pasta em que a app roda é lido antes (veja o.env.exampledo projeto) e não substitui o que já está no ambiente. Segredos ficam no ambiente, nunca no yaml commitado. - Chave ausente: uma chave que não está no yaml, ou um
${VAR}não definido, deixa o valor zerado (nilem ponteiros). Quem precisa do valor valida, como osecurity.SetSignKey, que impede a subida com um segredo curto.
As chaves que o prea.Run lê:
| Chave | Obrigatória | Padrão |
|---|---|---|
server.port, server.mode |
sim | — |
server.* (timeouts, max_header_bytes, trusted_proxies) |
não | veja Servidor |
logging.level, logging.format |
não | info, text; o prea.Config.Logging tem precedência |
database.url |
não | sem ela, a app sobe sem banco |
database.schema, database.migrations.* |
não | veja Model e schema |
security.jwt.secret |
não | aplicada só se existir |
Nos componentes, a tag value:"chave" preenche o campo na subida, como o @Value do Spring:
type Mailer struct {
Addr string `value:"mail.addr"`
Timeout time.Duration `value:"mail.timeout"` // "30s" no yaml
Mail MailConfig `value:"mail"` // o bloco inteiro, como o @ConfigurationProperties
}
type MailConfig struct {
Addr string `yaml:"addr"`
From string `yaml:"from"`
}
- Os valores são convertidos para o tipo do campo:
"true"→ bool,"8000"→ int,"30s"→time.Duration. - Como no
inject, um campo preenchido na declaração é mantido (Mailer{Addr: "localhost:25"}em testes). - O
*config.Configtambém é injetável (Config *config.Config `inject:""`), por exemplo para lercfg.Profile().
Módulos por profile: Profiles restringe o módulo a esses profiles. Vale para os módulos listados em core.App.Modules:
var Module = &core.Module{Name: "debug", Providers: []any{Handler{}}, Profiles: []string{"dev"}}
Testes
De uma app
Os testes de cada preá ficam ao lado do código (preas/item/item_test.go, como package item): enxergam o que não é exportado e montam só o próprio módulo. Os de ponta a ponta, com a app inteira, ficam em tests/. O prea tests roda os dois com o profile tests (config/application-tests.yaml, com o banco de testes e schema: create-drop).
func TestHealth(t *testing.T) {
app := prea.Test(t, prea.Config{Modules: []*core.Module{Module}})
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET /health: %d %s", w.Code, w.Body)
}
}
O prea.Test monta a app como o prea.Run, mas com o profile tests, e devolve o handler para o httptest. Ele roda a partir da raiz do projeto, o primeiro go.mod acima da pasta do teste, e fecha a app e o banco no fim do teste.
Do framework
O ./... na raiz cobre só o módulo da raiz; use o caminho, que cobre todos os módulos do go.work:
go test github.com/prea-framework/prea/...
# sem os testes que compilam projetos gerados
go test -short github.com/prea-framework/prea/...
# inclui os testes de integração do banco (criam as próprias tabelas com o database.schema)
PREA_TEST_DATABASE_URL="postgres://user:pass@localhost:5432/prea_test?sslmode=disable" go test github.com/prea-framework/prea/...
A CLI é testada em camadas (cmd/prea):
- unidade: formas dos nomes, nomes inválidos e a edição do
preas.goem vários formatos (lista vazia, numa linha, com comentários, em grupovar (...), import com alias); - comportamento: gera num projeto temporário e confere arquivos, registro e conteúdo; todo comando que falha deixa o projeto intacto. O
builde otestssão conferidos com umgofalso, que grava os argumentos e o profile; - projeto gerado:
prea new --localcria projetos ligados a este clone, com e sem--with-db;- o teste gera preás e middlewares neles e roda
go veteprea tests; com o banco, o CRUD inteiro pelo HTTP; prea build, e o binário respondendo/health;
prea dev: num projeto novo, ele sobe, reinicia quando um handler muda, mantém a versão antiga num erro de compilação e para o servidor no fim.
Licença
Veja LICENSE.
Documentation
¶
Overview ¶
Package prea runs a Preá app: it loads the config of the active profile, opens the database, applies the migrations and serves the modules. The app's main only passes what it declares:
prea.Run(prea.Config{Modules: preas.Modules, Middlewares: preas.Middlewares, Security: preas.Security})
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Run ¶
func Run(cfg Config)
Run starts the app and serves until SIGINT or SIGTERM; a startup error is logged and exits the process with code 1. With PREA_MIGRATE set (by prea migrate) it runs that migrations command instead of serving.
func Test ¶
Test builds the app like Run, with the tests profile (config/application-tests.yaml), and returns its handler for httptest:
app := prea.Test(t, prea.Config{Modules: []*core.Module{Module}})
app.ServeHTTP(w, httptest.NewRequest("GET", "/health", nil))
It runs from the project root, the nearest directory with a go.mod above the test's, so config/ and migrations/ are found from any package. The app and the database close when the test ends.
Types ¶
type Config ¶
type Config struct {
Modules []*core.Module
Middlewares []any
Security core.Security // Security.TrustedProxies, when nil, comes from server.trusted_proxies
Logging Logging
}
Config is what the app declares in code. The rest comes from config/application.yaml and the active profile:
- server.port and server.mode, required;
- server.read_header_timeout, read_timeout, write_timeout, idle_timeout, shutdown_timeout, max_header_bytes and trusted_proxies, with defaults;
- database.url (without it the app has no database), database.schema and database.migrations;
- security.jwt.secret, applied only when set.