Files
bot-pontos/backend/internal/httpapi/request.go
2026-10-23 22:31:02 -03:00

153 lines
5.0 KiB
Go

package httpapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"botpontos/internal/adapters/pontomais"
"botpontos/internal/app"
"botpontos/internal/domain"
"botpontos/internal/httpx"
)
const maxBodyBytes = 64 << 10
// decode lê o corpo JSON recusando campos desconhecidos.
func decode(w http.ResponseWriter, r *http.Request, out any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(out); err != nil {
title := "Corpo da requisição inválido"
if errors.Is(err, io.EOF) {
title = "Corpo da requisição vazio"
}
httpx.WriteProblem(w, http.StatusBadRequest, title, "BAD_REQUEST")
return false
}
return true
}
func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
httpx.WriteProblem(w, http.StatusBadRequest, "Identificador inválido", "BAD_REQUEST")
return 0, false
}
return id, true
}
func queryString(r *http.Request, key string) *string {
v := strings.TrimSpace(r.URL.Query().Get(key))
if v == "" {
return nil
}
return &v
}
func queryInt64(r *http.Request, key string) (*int64, error) {
v := queryString(r, key)
if v == nil {
return nil, nil
}
n, err := strconv.ParseInt(*v, 10, 64)
if err != nil {
return nil, domain.ValidationErrors{{Field: key, Message: "número inválido"}}
}
return &n, nil
}
func queryBool(r *http.Request, key string) (*bool, error) {
v := queryString(r, key)
if v == nil {
return nil, nil
}
b, err := strconv.ParseBool(*v)
if err != nil {
return nil, domain.ValidationErrors{{Field: key, Message: "use true ou false"}}
}
return &b, nil
}
// queryDate lê AAAA-MM-DD; end=true devolve o início do dia seguinte (limite exclusivo).
func queryDate(r *http.Request, key string, loc *time.Location, end bool) (*time.Time, error) {
v := queryString(r, key)
if v == nil {
return nil, nil
}
t, err := time.ParseInLocation(domain.DateLayout, *v, loc)
if err != nil {
return nil, domain.ValidationErrors{{Field: key, Message: "data inválida (use AAAA-MM-DD)"}}
}
if end {
t = t.AddDate(0, 0, 1)
}
return &t, nil
}
// writeError traduz os erros do domínio em respostas problem+json.
func (a *API) writeError(w http.ResponseWriter, err error) {
var validation domain.ValidationErrors
var credErr *app.CredentialError
switch {
case errors.As(err, &validation):
httpx.WriteProblemDetails(w, httpx.Problem{
Title: "Dados inválidos", Status: http.StatusUnprocessableEntity, Code: "VALIDATION", Errors: validation,
})
case errors.As(err, &credErr):
if errors.Is(err, pontomais.ErrAuthRejected) {
httpx.WriteProblem(w, http.StatusUnprocessableEntity,
"A PontoMais recusou o e-mail ou a senha; a credencial anterior foi mantida", "CREDENTIALS_REJECTED")
return
}
httpx.WriteProblem(w, http.StatusBadGateway,
"Não foi possível validar na PontoMais agora; a credencial anterior foi mantida", "POINTS_UNAVAILABLE")
case errors.Is(err, domain.ErrNotFound):
httpx.WriteProblem(w, http.StatusNotFound, "Registro não encontrado", "NOT_FOUND")
case errors.Is(err, domain.ErrCredentialsRequired):
httpx.WriteProblem(w, http.StatusConflict, "Configure e valide a credencial da PontoMais antes", "CREDENTIALS_REQUIRED")
case errors.Is(err, domain.ErrUserNotActive):
httpx.WriteProblem(w, http.StatusConflict, "O usuário precisa estar ativo", "USER_NOT_ACTIVE")
case errors.Is(err, domain.ErrLastOwner):
httpx.WriteProblem(w, http.StatusConflict, "É preciso manter pelo menos um owner ativo", "LAST_OWNER")
case errors.Is(err, domain.ErrInvalidState):
httpx.WriteProblem(w, http.StatusConflict, capitalize(err.Error()), "INVALID_STATE")
case errors.Is(err, domain.ErrInvalidLogin):
httpx.WriteProblem(w, http.StatusUnauthorized, "Usuário ou senha inválidos", "INVALID_LOGIN")
case errors.Is(err, domain.ErrAccountLocked):
httpx.WriteProblem(w, http.StatusLocked, "Conta bloqueada por tentativas de login; tente mais tarde", "ACCOUNT_LOCKED")
case errors.Is(err, domain.ErrNotConfigured):
httpx.WriteProblem(w, http.StatusServiceUnavailable, "Integração não configurada no servidor", "NOT_CONFIGURED")
case errors.Is(err, pontomais.ErrAuthRejected):
httpx.WriteProblem(w, http.StatusBadGateway, "A PontoMais recusou a credencial gravada", "CREDENTIALS_REJECTED")
case isPointsError(err):
httpx.WriteProblem(w, http.StatusBadGateway, "A PontoMais não respondeu; tente novamente", "POINTS_UNAVAILABLE")
default:
a.log.Error("erro interno na API", "error", err)
httpx.WriteProblem(w, http.StatusInternalServerError, "Erro interno", "INTERNAL")
}
}
func isPointsError(err error) bool {
return strings.Contains(err.Error(), "PontoMais")
}
func capitalize(s string) string {
first, size := utf8.DecodeRuneInString(s)
if first == utf8.RuneError {
return s
}
return string(unicode.ToUpper(first)) + s[size:]
}
func badRequest(field, message string) error {
return domain.ValidationErrors{{Field: field, Message: message}}
}