DEV Community

CPFHub
CPFHub

Posted on

Validando e consultando CPF em Go: pacote enxuto, context e testes table-driven

Transparência: sou fundador da CPFHub, uma das APIs citadas no final. O pacote de validação é independente.

Go é ótimo para serviços de backend que recebem cadastro em alto volume. Vamos escrever um pacote cpf pequeno, sem dependências, e um cliente HTTP que respeita context, timeout e limites de taxa.

1. O pacote

package cpf

import "strings"

// Normalize remove tudo que não for dígito ASCII.
func Normalize(s string) string {
    var b strings.Builder
    for _, r := range s {
        if r >= '0' && r <= '9' {
            b.WriteRune(r)
        }
    }
    return b.String()
}

// Valid confere tamanho, sequência repetida e dígitos verificadores.
func Valid(s string) bool {
    c := Normalize(s)
    if len(c) != 11 || strings.Count(c, c[:1]) == 11 {
        return false
    }
    for t := 9; t < 11; t++ {
        sum := 0
        for i := 0; i < t; i++ {
            sum += int(c[i]-'0') * (t + 1 - i)
        }
        if int(c[t]-'0') != sum*10%11%10 {
            return false
        }
    }
    return true
}

// Format aplica a máscara 000.000.000-00.
func Format(s string) string {
    c := Normalize(s)
    if len(c) != 11 {
        return s
    }
    return c[:3] + "." + c[3:6] + "." + c[6:9] + "-" + c[9:]
}
Enter fullscreen mode Exit fullscreen mode

2. Testes table-driven

func TestValid(t *testing.T) {
    cases := []struct {
        in   string
        want bool
    }{
        {"529.982.247-25", true},
        {"52998224725", true},
        {"11111111111", false},
        {"52998224724", false},
        {"", false},
    }
    for _, c := range cases {
        if got := Valid(c.in); got != c.want {
            t.Errorf("Valid(%q) = %v, want %v", c.in, got, c.want)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Vale adicionar um fuzz test (go test -fuzz) garantindo que Valid nunca entra em pânico com entrada arbitrária.

3. Cliente HTTP

Depois que o DV passa, uma API externa confirma se o CPF existe. Um cliente idiomático:

type Data struct {
    CPF       string `json:"cpf"`
    Name      string `json:"name"`
    Gender    string `json:"gender"`
    BirthDate string `json:"birthDate"`
}

var ErrNotFound = errors.New("cpf não encontrado")

type RateLimitError struct{ RetryAfter string }

func (e RateLimitError) Error() string { return "limite por minuto; retry-after " + e.RetryAfter }

type Client struct {
    HTTP   *http.Client
    APIKey string
}

func (c *Client) Lookup(ctx context.Context, raw string) (*Data, error) {
    n := cpf.Normalize(raw)
    if !cpf.Valid(n) {
        return nil, errors.New("cpf inválido")
    }
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
        "https://api.cpfhub.io/cpf/"+n, nil)
    req.Header.Set("x-api-key", c.APIKey)

    res, err := c.HTTP.Do(req)
    if err != nil {
        return nil, err
    }
    defer res.Body.Close()

    switch res.StatusCode {
    case http.StatusOK:
        var body struct {
            Success bool `json:"success"`
            Data    Data `json:"data"`
        }
        if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
            return nil, err
        }
        return &body.Data, nil
    case http.StatusNotFound:
        return nil, ErrNotFound
    case http.StatusTooManyRequests:
        return nil, RateLimitError{res.Header.Get("Retry-After")}
    default:
        return nil, fmt.Errorf("status %d", res.StatusCode)
    }
}
Enter fullscreen mode Exit fullscreen mode

Uso:

c := &Client{HTTP: &http.Client{Timeout: 10 * time.Second}, APIKey: os.Getenv("CPFHUB_API_KEY")}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
d, err := c.Lookup(ctx, "529.982.247-25")
switch {
case errors.Is(err, ErrNotFound):
    // trate como resultado normal
case err != nil:
    var rl RateLimitError
    if errors.As(err, &rl) { /* reagende */ }
}
Enter fullscreen mode Exit fullscreen mode

4. Controlando a taxa do seu lado

Toda API tem limite por minuto. Em vez de descobrir isso tomando 429, limite antes com golang.org/x/time/rate:

lim := rate.NewLimiter(rate.Every(2*time.Second), 1) // 30/min
for _, doc := range docs {
    if err := lim.Wait(ctx); err != nil { break }
    go process(doc)
}
Enter fullscreen mode Exit fullscreen mode

Para listas grandes, prefira um endpoint de lote se o provedor tiver: uma requisição em vez de milhares.

5. Dicas

  • Nunca logue o CPF completo; use Format com mascaramento (***.982.247-**) nos logs.
  • Use errors.Is/As em vez de comparar strings de erro.
  • Não reutilize http.DefaultClient: ele não tem timeout.

Sobre a CPFHub

O cliente acima já está apontado para a CPFHub: GET /cpf/{cpf} com x-api-key, ~150 ms típicos (não SLA), 404 sem custo, e um POST /cpf/bulk para listas de até 10.000 CPFs. O plano gratuito tem 50 créditos por mês. Mais na documentação.

Top comments (0)