diff --git a/README.md b/README.md index 1ac7155..bcaad29 100644 --- a/README.md +++ b/README.md @@ -4,71 +4,103 @@ Data Validator API > Upwork portfolio thumbnail — Data validation API (email, CPF, phone, etc.) -The Data Validator API is a service designed to validate common data inputs, such as email, CPF, name, phone number, RG, CEP, and credit card number. It supports flexible input formats, sanitizes data to ensure consistency, and returns structured JSON responses. -Features - -- Email Validation: Verifies email format and normalizes case. -- CPF Validation: Checks Brazilian CPF validity (including check digits) and caches result in Redis. -- Name Validation: Removes accents, strips invalid chars, normalizes spaces, and converts to uppercase. -- Phone Validation: Validates Brazilian phone numbers with DDD and mobile rules. -- RG Validation: Validates basic Brazilian RG format. -- CEP Validation: Validates CEP length and basic invalid values. -- Credit Card Validation: Uses Luhn algorithm and identifies card brand. - -API Endpoint - -GET /validate - -This endpoint receives query parameters and validates exactly one parameter at a time. - -Query Parameters - -- email (optional) - Email address to validate. -- cpf (optional) - Brazilian CPF number to validate, accepts different formats. -- name (optional) - Name to validate and sanitize. -- telephone (optional) - Phone number to validate. -- phone (optional) - Alias for `telephone`. -- plastic (optional) - Credit card number to validate. -- rg (optional) - Brazilian RG to validate. -- cep (optional) - CEP (postal code) to validate. - -Status Codes - -- 200: Valid value. -- 400: Missing parameter or multiple parameters sent. -- 422: Parameter provided but failed validation. - -Response Format - -The API returns a JSON object with the validation result: - -- status_code: HTTP status code. -- error_code: Optional machine-readable error code. -- parameter_key: Input parameter key. -- raw_parameter_value: Original input. -- parameter_value: Sanitized value. -- is_valid: Whether the value is valid. -- message: Human-readable message. -- request_id: Unique request identifier. -- timestamp: Request timestamp. -- execution_time_ms: Processing time. -- from_cache: Whether result came from cache (CPF only). - -Examples - -- Valid email - - Request: `GET /validate?email=USER@Test.COM` - - Response: `200` with `parameter_value: "user@test.com"` and `is_valid: true`. - -- Invalid CPF - - Request: `GET /validate?cpf=111.111.111-11` - - Response: `422` with `error_code: "VALIDATION_FAILED"` and `is_valid: false`. - -- Valid phone using alias - - Request: `GET /validate?phone=+55 (11) 91234-5678` - - Response: `200` with `parameter_key: "telephone"` and `parameter_value: "11912345678"`. - -- Multiple parameters (not allowed) - - Request: `GET /validate?email=a@a.com&cpf=52998224725` - - Response: `400` with `error_code: "MULTIPLE_PARAMETERS"`. +A Go library for validating Brazilian documents, and an HTTP service built on it. + +```bash +go get github.com/jeferson0306/api-data-validator/validate +``` + +```go +result := validate.CPF("529.982.247-25") +result.Valid // true +result.Normalized // "52998224725" +``` + +The library is pure: no network, no cache, no configuration, no state. Importing +it opens no connections and reads no environment. Validation is arithmetic, and +arithmetic that needs a server is arithmetic you cannot trust when the server is +down. + +Values are **checked, not laundered**. Formatting a person legitimately types — +dots, dashes, spaces, the slash in a CNPJ — is accepted and removed. Anything +else is a rejection. Stripping unexpected characters and validating what remains +accepts `abc529.982.247-25` as a CPF, which is how a validator ends up letting +junk into a database. + +## What it checks + +| | | +|---|---| +| People | CPF, PIS/PASEP/NIT/NIS, RG, título de eleitor, CNH, full name | +| Companies | CNPJ, inscrição estadual (**all 27 states**) | +| Either | `documento` — whichever of CPF or CNPJ the digits describe | +| Vehicles | RENAVAM, plate (pre-Mercosul and Mercosul) | +| Money | PIX key (all five forms), card number with brand, boleto linha digitável | +| Contact | email, phone, CEP | + +Every algorithm was checked against a published reference or the issuing state's +own worked example before it shipped. Two of them disagree with the reference +implementation they were compared against, in both cases because the reference +is wrong — see `validate/inscricao_estadual_test.go`. + +## Command line + +```bash +go install github.com/jeferson0306/api-data-validator/cmd/brdoc@latest + +brdoc cpf 529.982.247-25 && echo accepted +brdoc ie 0100482300112 AC +brdoc --json cnpj 33.000.167/0001-01 | jq .normalized +brdoc list +``` + +Exit status is 0 for valid, 1 for invalid and 2 for a usage error, so it +composes with everything else a shell can do. + +## HTTP service + +``` +GET /validate?cpf=529.982.247-25 +GET /validate?ie=0100482300112&uf=AC +POST /validate/batch one form, one request +GET /health +GET /swagger/index.html +``` + +`POST /validate/batch` takes a list of items, each with its own type, and +answers one result per item plus a summary. A form with eight fields is one +request rather than eight. + +## Performance + +``` +BenchmarkCPF-12 522 ns/op +BenchmarkCNPJ-12 499 ns/op +BenchmarkBoleto-12 864 ns/op +BenchmarkStateRegistration-12 248 ns/op +``` + +Worth knowing before reaching for a cache: a CPF check costs about half a +microsecond, against a Redis round trip measured at 150–220 ms in production. +The cache is opt-in through `REDIS_ADDR` and off by default. + +## Layout + +``` +validate/ the library — pure, importable, no dependencies beyond x/text +internal/cache/ the service's optional CPF cache, kept out of the library +handlers/ HTTP handlers +middleware/ CORS +observability/ request logging that never records the value being validated +cmd/brdoc/ the command line tool +main.go the service +``` + +## Development + +```bash +go test ./... -race +go test ./validate -bench . +go run ./cmd/brdoc cpf 529.982.247-25 +``` diff --git a/cmd/brdoc/main.go b/cmd/brdoc/main.go new file mode 100644 index 0000000..a86465f --- /dev/null +++ b/cmd/brdoc/main.go @@ -0,0 +1,97 @@ +// Command brdoc checks Brazilian documents from the shell. +// +// It exits 0 when the value is valid and 1 when it is not, so it composes with +// everything else a shell can do: +// +// brdoc cpf 529.982.247-25 && echo accepted +// brdoc ie 0100482300112 AC +// brdoc --json cnpj 33.000.167/0001-01 | jq .normalized +// +// Nothing leaves the machine. The library it wraps performs arithmetic and +// makes no network calls, which is the point of having a CLI at all. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strings" + + "github.com/jeferson0306/api-data-validator/validate" +) + +const ( + exitValid = 0 + exitInvalid = 1 + exitUsage = 2 +) + +func main() { + asJSON := flag.Bool("json", false, "print the result as JSON") + flag.Usage = usage + flag.Parse() + + args := flag.Args() + if len(args) == 1 && args[0] == "list" { + fmt.Println(strings.Join(validate.Keys(), "\n")) + os.Exit(exitValid) + } + + if len(args) < 2 { + usage() + os.Exit(exitUsage) + } + + key, value := args[0], args[1] + qualifier := "" + if len(args) > 2 { + qualifier = args[2] + } + + result, known := validate.ByKey(key, value, qualifier) + if !known { + fmt.Fprintf(os.Stderr, "unknown document type %q\nknown types: %s\n", + key, strings.Join(validate.Keys(), ", ")) + os.Exit(exitUsage) + } + + if *asJSON { + // Field names match the library's, so a script reading this and a + // program importing the package see the same shape. + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ + "valid": result.Valid, + "normalized": result.Normalized, + "reason": result.Reason, + }) + } else { + mark := "invalid" + if result.Valid { + mark = "valid" + } + fmt.Printf("%-8s %s %s\n", mark, result.Normalized, result.Reason) + } + + if !result.Valid { + os.Exit(exitInvalid) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `brdoc — check Brazilian documents + +usage: + brdoc [--json] [qualifier] + brdoc list + + The qualifier is only used by "ie", which takes the issuing state: + brdoc ie 0100482300112 AC + +exit status: + 0 valid + 1 invalid + 2 usage error + +`) + flag.PrintDefaults() +} diff --git a/go.mod b/go.mod index 33ab7b0..bda1375 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module DataValidatorAPI +module github.com/jeferson0306/api-data-validator go 1.22.5 diff --git a/handlers/batch.go b/handlers/batch.go index e50412d..3c77b15 100644 --- a/handlers/batch.go +++ b/handlers/batch.go @@ -7,8 +7,9 @@ import ( "strings" "time" - "DataValidatorAPI/models" - "DataValidatorAPI/utils" + "github.com/jeferson0306/api-data-validator/internal/cache" + "github.com/jeferson0306/api-data-validator/models" + "github.com/jeferson0306/api-data-validator/validate" "github.com/google/uuid" ) @@ -101,21 +102,31 @@ func validateItem(item models.BatchItem) models.BatchResult { return result } - outcome, known := utils.Validate(key, item.Value, item.Qualifier) + // CPF is the one check routed through the cache, so that from_cache means + // the same thing on both endpoints. + if key == "cpf" { + outcome, fromCache := cache.CPF(item.Value) + result.Value = outcome.Normalized + result.IsValid = outcome.Valid + result.Message = outcome.Reason + result.FromCache = fromCache + return result + } + + outcome, known := validate.ByKey(key, item.Value, item.Qualifier) if !known { // Distinguished from a failed validation on purpose: an unknown key is // the caller's mistake, and reporting it as "invalid" would suggest the // value was checked and rejected. result.ErrorCode = "UNKNOWN_KEY" result.Message = "Unknown document type " + key + - " (expected one of " + strings.Join(utils.SupportedKeys(), ", ") + ")" + " (expected one of " + strings.Join(validate.Keys(), ", ") + ")" return result } - result.Value = outcome.Sanitized + result.Value = outcome.Normalized result.IsValid = outcome.Valid - result.Message = outcome.Message - result.FromCache = outcome.FromCache + result.Message = outcome.Reason return result } diff --git a/handlers/batch_test.go b/handlers/batch_test.go index 757a6b7..92d8f21 100644 --- a/handlers/batch_test.go +++ b/handlers/batch_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "DataValidatorAPI/models" + "github.com/jeferson0306/api-data-validator/models" ) func postBatch(t *testing.T, body string) (*httptest.ResponseRecorder, models.BatchResponse) { diff --git a/handlers/health.go b/handlers/health.go index 810407e..9e90466 100644 --- a/handlers/health.go +++ b/handlers/health.go @@ -3,7 +3,7 @@ package handlers import ( "net/http" - "DataValidatorAPI/utils" + "github.com/jeferson0306/api-data-validator/internal/cache" "github.com/gin-gonic/gin" ) @@ -17,13 +17,19 @@ import ( // visible — a silently unreachable Redis is how this service spent its first // deployment paying latency for a cache that never returned a hit. func HealthHandler(c *gin.Context) { - cache := "unreachable" - if utils.CacheHealthy() { - cache = "ok" + // A local named "cache" would shadow the package; more importantly, there + // are now three states rather than two. "disabled" is not a fault. + status := "disabled" + switch { + case !cache.Enabled(): + case cache.Healthy(): + status = "ok" + default: + status = "unreachable" } c.JSON(http.StatusOK, gin.H{ "status": "ok", - "cache": cache, + "cache": status, }) } diff --git a/handlers/validate.go b/handlers/validate.go index 527aaa9..ea1bb1b 100644 --- a/handlers/validate.go +++ b/handlers/validate.go @@ -1,9 +1,10 @@ package handlers import ( - "DataValidatorAPI/models" - "DataValidatorAPI/utils" "encoding/json" + "github.com/jeferson0306/api-data-validator/internal/cache" + "github.com/jeferson0306/api-data-validator/models" + "github.com/jeferson0306/api-data-validator/validate" "net/http" "strings" "time" @@ -72,8 +73,19 @@ func ValidateHandler(w http.ResponseWriter, r *http.Request) { // The qualifier is only read for the validations that take one; passing it // unconditionally keeps this loop from growing a special case per document. - outcome, _ := utils.Validate(key, value, r.URL.Query().Get("uf")) - response = createResponse(key, value, outcome.Sanitized, outcome.Valid, outcome.Message, start, outcome.FromCache) + // CPF is the one check routed through the cache, because from_cache is part + // of the published response. Everything else goes straight to the library. + var ( + result validate.Result + fromCache bool + ) + if key == "cpf" { + result, fromCache = cache.CPF(value) + } else { + result, _ = validate.ByKey(key, value, r.URL.Query().Get("uf")) + } + + response = createResponse(key, value, result.Normalized, result.Valid, result.Reason, start, fromCache) writeResponse(w, response) } @@ -82,7 +94,7 @@ func ValidateHandler(w http.ResponseWriter, r *http.Request) { // utils.ValidationKeys documents. "phone" remains an accepted alias for // "telephone" because it was published that way. func firstProvided(r *http.Request) (string, string) { - for _, key := range utils.ValidationKeys { + for _, key := range validate.Keys() { value := r.URL.Query().Get(key) if key == "telephone" { value = firstNonEmpty(value, r.URL.Query().Get("phone")) @@ -97,7 +109,7 @@ func firstProvided(r *http.Request) (string, string) { func validationCount(r *http.Request) int { count := 0 - for _, key := range utils.ValidationKeys { + for _, key := range validate.Keys() { value := r.URL.Query().Get(key) if key == "telephone" { value = firstNonEmpty(value, r.URL.Query().Get("phone")) diff --git a/handlers/validate_test.go b/handlers/validate_test.go index a7ce908..6494219 100644 --- a/handlers/validate_test.go +++ b/handlers/validate_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "DataValidatorAPI/models" + "github.com/jeferson0306/api-data-validator/models" ) func TestValidateHandlerMissingParameter(t *testing.T) { diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..53bc27a --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,109 @@ +// Package cache remembers CPF results between requests. +// +// It lives here rather than in the validate package on purpose: validation is +// arithmetic and must work with nothing else running, while a cache is +// infrastructure with a connection, a timeout and a failure mode. Keeping them +// apart is what lets the library be imported without opening a socket. +package cache + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "log/slog" + "os" + "time" + + "github.com/go-redis/redis/v8" + + "github.com/jeferson0306/api-data-validator/validate" +) + +const ( + // timeout bounds every call. Validation itself is measured in microseconds, + // so an unreachable cache must never hold a request open — the answer is + // recomputed instead, which costs less than waiting. + timeout = 150 * time.Millisecond + // retention is generous because the answer cannot change: a CPF that is + // valid today was valid yesterday. + retention = 24 * time.Hour +) + +var client = redis.NewClient(&redis.Options{Addr: address()}) + +func address() string { + if addr := os.Getenv("REDIS_ADDR"); addr != "" { + return addr + } + return "localhost:6379" +} + +// keyFor hashes the value before it becomes a key. +// +// The key used to be "cpf:" followed by the CPF itself, which put every CPF +// ever checked into the cache in the clear. A digest keys just as well — equal +// inputs still collide onto one entry — while storing nothing that identifies +// a person. +func keyFor(value string) string { + digest := sha256.Sum256([]byte(value)) + return "cpf:" + hex.EncodeToString(digest[:]) +} + +// CPF validates through the cache, reporting whether the answer was remembered. +// +// Note what this costs. Building the key needs the normalised value, and +// normalising is most of the validation, so the answer is already computed +// before the cache is consulted — see the benchmarks in the validate package, +// where a CPF check runs in about 500 nanoseconds against a Redis round trip +// measured at 150 to 220 milliseconds in production. The cache is retained +// because from_cache is part of the published response; the numbers argue for +// removing both. +func CPF(value string) (validate.Result, bool) { + result := validate.CPF(value) + + if !Enabled() { + return result, false + } + + key := keyFor(result.Normalized) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if cached, err := client.Get(ctx, key).Result(); err == nil { + result.Valid = cached == "true" + return result, true + } + + stored := "false" + if result.Valid { + stored = "true" + } + + if err := client.Set(ctx, key, stored, retention).Err(); err != nil { + // Debug, not error: a missing cache is a degraded mode, not a failure, + // and at request rate an unreachable Redis would drown the log. + // Healthy is what surfaces the condition. + slog.Debug("cache write failed", slog.String("error", err.Error())) + } + + return result, false +} + +// Enabled reports whether a cache was configured at all. Unset, the service +// skips the round trip rather than attempting one and timing out per request. +func Enabled() bool { + return os.Getenv("REDIS_ADDR") != "" +} + +// Healthy reports whether the cache is reachable, so a health endpoint can say +// so rather than the service degrading in silence. +func Healthy() bool { + if !Enabled() { + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return client.Ping(ctx).Err() == nil +} diff --git a/main.go b/main.go index 444c9b3..ae5c823 100644 --- a/main.go +++ b/main.go @@ -10,10 +10,10 @@ import ( "syscall" "time" - "DataValidatorAPI/docs" - "DataValidatorAPI/handlers" - "DataValidatorAPI/middleware" - "DataValidatorAPI/observability" + "github.com/jeferson0306/api-data-validator/docs" + "github.com/jeferson0306/api-data-validator/handlers" + "github.com/jeferson0306/api-data-validator/middleware" + "github.com/jeferson0306/api-data-validator/observability" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" diff --git a/utils/cache_key_test.go b/utils/cache_key_test.go deleted file mode 100644 index 0d7a03a..0000000 --- a/utils/cache_key_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package utils - -import ( - "strings" - "testing" -) - -// The key used to be "cpf:", so every CPF ever validated sat in -// Redis in the clear. -func TestCacheKeyDoesNotContainTheValue(t *testing.T) { - key := cacheKeyFor("cpf", "52998224725") - - if strings.Contains(key, "52998224725") { - t.Fatalf("the cache key still carries the CPF: %s", key) - } - if !strings.HasPrefix(key, "cpf:") { - t.Fatalf("expected the namespace prefix to survive, got %s", key) - } -} - -func TestCacheKeyIsStableAndDistinct(t *testing.T) { - // Equal inputs must still collide onto one entry, or the cache stops working. - if cacheKeyFor("cpf", "52998224725") != cacheKeyFor("cpf", "52998224725") { - t.Fatal("the same value produced two different keys") - } - if cacheKeyFor("cpf", "52998224725") == cacheKeyFor("cpf", "16899535009") { - t.Fatal("two different values collided") - } -} diff --git a/utils/dispatch.go b/utils/dispatch.go deleted file mode 100644 index 022cb44..0000000 --- a/utils/dispatch.go +++ /dev/null @@ -1,83 +0,0 @@ -package utils - -// One table maps a parameter name to the validator behind it. Both the -// single-value endpoint and the batch endpoint read from it, so a new document -// becomes available on both the moment it is added here — and neither can -// silently fall behind the other. - -// Outcome is what a validator answers, independent of how the request arrived. -type Outcome struct { - // Sanitized is the value normalised for storage, or the raw input when it - // was rejected outright. - Sanitized string - Valid bool - Message string - // FromCache is only ever true for CPF, the one check expensive enough to - // be worth remembering. - FromCache bool -} - -// Qualifier is the second argument some validations need. Only inscrição -// estadual uses one today — the issuing state — but the signature exists so a -// document that needs context does not have to break the table to get it. -type validatorFunc func(value, qualifier string) Outcome - -func plain(f func(string) (bool, string, string)) validatorFunc { - return func(value, _ string) Outcome { - valid, sanitized, message := f(value) - return Outcome{Sanitized: sanitized, Valid: valid, Message: message} - } -} - -var validators = map[string]validatorFunc{ - "email": plain(ValidateEmail), - "cpf": func(value, _ string) Outcome { - valid, sanitized, message, fromCache := ValidateCPFWithCache(value) - return Outcome{Sanitized: sanitized, Valid: valid, Message: message, FromCache: fromCache} - }, - "cnpj": plain(ValidateCNPJ), - "documento": plain(ValidateDocument), - "ie": func(value, qualifier string) Outcome { - valid, sanitized, message := ValidateInscricaoEstadual(value, qualifier) - return Outcome{Sanitized: sanitized, Valid: valid, Message: message} - }, - "pis": plain(ValidatePIS), - "cnh": plain(ValidateCNH), - "renavam": plain(ValidateRenavam), - "boleto": plain(ValidateBoleto), - "titulo": plain(ValidateTituloEleitor), - "placa": plain(ValidatePlate), - "pix": plain(ValidatePixKey), - "name": plain(ValidateName), - "telephone": plain(ValidatePhone), - "plastic": plain(ValidatePlastic), - "rg": plain(ValidateRG), - "cep": plain(ValidateCEP), -} - -// ValidationKeys is the order the single-value endpoint checks parameters in. -// It is explicit rather than derived from the map, because map iteration order -// is random in Go and the endpoint's behaviour must not be. -var ValidationKeys = []string{ - "email", "cpf", "cnpj", "documento", "ie", "pis", "titulo", - "cnh", "renavam", "placa", "boleto", "pix", - "name", "telephone", "plastic", "rg", "cep", -} - -// SupportedKeys reports what can be validated, for error messages. -func SupportedKeys() []string { - keys := make([]string, len(ValidationKeys)) - copy(keys, ValidationKeys) - return keys -} - -// Validate runs the validator registered for key. The second return reports -// whether the key is one this service knows at all, which is a different -// failure from a value that did not pass. -func Validate(key, value, qualifier string) (Outcome, bool) { - validate, known := validators[key] - if !known { - return Outcome{}, false - } - return validate(value, qualifier), true -} diff --git a/validate/benchmark_test.go b/validate/benchmark_test.go new file mode 100644 index 0000000..8afc53b --- /dev/null +++ b/validate/benchmark_test.go @@ -0,0 +1,33 @@ +package validate_test + +import ( + "testing" + + "github.com/jeferson0306/api-data-validator/validate" +) + +// These exist to answer one question the service's design depends on: is any of +// this expensive enough to be worth remembering between requests? +func BenchmarkCPF(b *testing.B) { + for i := 0; i < b.N; i++ { + validate.CPF("529.982.247-25") + } +} + +func BenchmarkCNPJ(b *testing.B) { + for i := 0; i < b.N; i++ { + validate.CNPJ("33.000.167/0001-01") + } +} + +func BenchmarkBoleto(b *testing.B) { + for i := 0; i < b.N; i++ { + validate.Boleto("00190000090114971860168524522114675860000102656") + } +} + +func BenchmarkStateRegistration(b *testing.B) { + for i := 0; i < b.N; i++ { + validate.StateRegistration("0100482300112", "AC") + } +} diff --git a/utils/boleto.go b/validate/boleto.go similarity index 95% rename from utils/boleto.go rename to validate/boleto.go index dca4326..57b4cfa 100644 --- a/utils/boleto.go +++ b/validate/boleto.go @@ -1,4 +1,4 @@ -package utils +package validate import "regexp" @@ -29,8 +29,8 @@ var barcodeOrder = [][2]int{{0, 4}, {32, 47}, {4, 9}, {10, 20}, {21, 31}} // barcode is reassembled — inside it, not at the end. const barcodeCheckDigitPosition = 4 -// ValidateBoleto checks the 47-digit linha digitável of a bank slip. -func ValidateBoleto(boleto string) (bool, string, string) { +// checkBoleto checks the 47-digit linha digitável of a bank slip. +func checkBoleto(boleto string) (bool, string, string) { if !boletoChars.MatchString(boleto) { return false, boleto, "Invalid boleto (unexpected characters)" } diff --git a/validate/bykey.go b/validate/bykey.go new file mode 100644 index 0000000..556dc2a --- /dev/null +++ b/validate/bykey.go @@ -0,0 +1,59 @@ +package validate + +// Dispatch by string, for callers that do not know the document type at compile +// time — a form driven by configuration, or an HTTP endpoint taking the type +// from a query parameter. + +type checker func(value, qualifier string) Result + +func ignoringQualifier(f func(string) Result) checker { + return func(value, _ string) Result { return f(value) } +} + +var checkers = map[string]checker{ + "cpf": ignoringQualifier(CPF), + "cnpj": ignoringQualifier(CNPJ), + "documento": ignoringQualifier(Document), + "pis": ignoringQualifier(PIS), + "titulo": ignoringQualifier(VoterID), + "cnh": ignoringQualifier(CNH), + "renavam": ignoringQualifier(Renavam), + "placa": ignoringQualifier(Plate), + "boleto": ignoringQualifier(Boleto), + "pix": ignoringQualifier(PixKey), + "email": ignoringQualifier(Email), + "name": ignoringQualifier(FullName), + "telephone": ignoringQualifier(Phone), + "plastic": ignoringQualifier(Card), + "rg": ignoringQualifier(RG), + "cep": ignoringQualifier(CEP), + "ie": StateRegistration, +} + +// keyOrder is explicit rather than derived from the map. Go randomises map +// iteration, and a caller walking the supported types deserves a stable answer. +var keyOrder = []string{ + "email", "cpf", "cnpj", "documento", "ie", "pis", "titulo", + "cnh", "renavam", "placa", "boleto", "pix", + "name", "telephone", "plastic", "rg", "cep", +} + +// Keys lists the document types [ByKey] accepts, in a stable order. +func Keys() []string { + keys := make([]string, len(keyOrder)) + copy(keys, keyOrder) + return keys +} + +// ByKey runs the check registered for key. The qualifier carries the extra +// context a type may need — today only "ie", which takes the issuing state. +// +// The second return distinguishes an unknown key from a value that failed: +// they are different mistakes and deserve different handling. +func ByKey(key, value, qualifier string) (Result, bool) { + check, known := checkers[key] + if !known { + return Result{}, false + } + return check(value, qualifier), true +} diff --git a/utils/checkdigit.go b/validate/checkdigit.go similarity index 98% rename from utils/checkdigit.go rename to validate/checkdigit.go index e9d5b16..c207194 100644 --- a/utils/checkdigit.go +++ b/validate/checkdigit.go @@ -1,4 +1,4 @@ -package utils +package validate // Most Brazilian document numbers end in check digits computed the same way: a // weighted sum of the preceding digits, taken modulo 11. Only the weights diff --git a/utils/cnpj_test.go b/validate/cnpj_test.go similarity index 95% rename from utils/cnpj_test.go rename to validate/cnpj_test.go index 003841f..f35cc95 100644 --- a/utils/cnpj_test.go +++ b/validate/cnpj_test.go @@ -1,4 +1,4 @@ -package utils +package validate import "testing" @@ -30,7 +30,7 @@ func TestValidateCNPJ(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - isValid, sanitized, message := ValidateCNPJ(tt.cnpj) + isValid, sanitized, message := checkCNPJ(tt.cnpj) if isValid != tt.valid { t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) } diff --git a/utils/validators.go b/validate/core.go similarity index 66% rename from utils/validators.go rename to validate/core.go index 58cbfe9..7654886 100644 --- a/utils/validators.go +++ b/validate/core.go @@ -1,26 +1,13 @@ -package utils +package validate import ( - "context" - "crypto/sha256" - "encoding/hex" - "log/slog" - "os" "regexp" "strings" - "time" "unicode" - "github.com/go-redis/redis/v8" "golang.org/x/text/unicode/norm" ) -// cacheTimeout bounds every Redis call. Without it a hung or unreachable cache -// blocks the request for as long as the client is willing to wait — validation -// itself takes microseconds, so anything slower than this is not worth waiting -// for and the answer is recomputed instead. -const cacheTimeout = 150 * time.Millisecond - var ( nonDigitsRegex = regexp.MustCompile(`\D`) emailRegex = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`) @@ -47,81 +34,8 @@ var ( nameChars = regexp.MustCompile(`^[\p{L}\s\-'.]*$`) ) -// Initialize Redis client -var rdb = redis.NewClient(&redis.Options{ - Addr: getRedisAddr(), -}) - -// CacheHealthy reports whether Redis is actually reachable, so /health can say -// so instead of the service degrading silently — which is how a broken cache -// went unnoticed in production. -func CacheHealthy() bool { - ctx, cancel := context.WithTimeout(context.Background(), cacheTimeout) - defer cancel() - return rdb.Ping(ctx).Err() == nil -} - -func getRedisAddr() string { - if addr := os.Getenv("REDIS_ADDR"); addr != "" { - return addr - } - return "localhost:6379" -} - -// cacheKeyFor hashes the value before it becomes a Redis key. -// -// The key used to be "cpf:", which put every CPF ever validated -// into the cache in the clear, readable by anyone with access to it. A SHA-256 -// digest keys just as well — equal inputs still collide onto one entry — while -// storing nothing that identifies a person. -func cacheKeyFor(prefix, value string) string { - digest := sha256.Sum256([]byte(value)) - return prefix + ":" + hex.EncodeToString(digest[:]) -} - -// ValidateCPFWithCache validates the CPF and uses cache to avoid duplicate validations. -func ValidateCPFWithCache(cpf string) (bool, string, string, bool) { - // Before the cache, not after: the key is built from the extracted digits, so - // "abc529.982.247-25" and "529.982.247-25" would otherwise share an entry and - // the dirty value would inherit the clean one's cached "true". - if !cpfChars.MatchString(cpf) { - return false, cpf, "Invalid CPF format (unexpected characters)", false - } - - sanitizedCPF := nonDigitsRegex.ReplaceAllString(cpf, "") - cacheKey := cacheKeyFor("cpf", sanitizedCPF) - - ctx, cancel := context.WithTimeout(context.Background(), cacheTimeout) - defer cancel() - - cachedResult, err := rdb.Get(ctx, cacheKey).Result() - if err == nil { - isValid := cachedResult == "true" - message := "Valid CPF format" - if !isValid { - message = "Invalid CPF format" - } - return isValid, sanitizedCPF, message, true - } - - isValid, sanitizedCPF, message := ValidateCPF(cpf) - - cacheValue := "false" - if isValid { - cacheValue = "true" - } - if err := rdb.Set(ctx, cacheKey, cacheValue, 24*time.Hour).Err(); err != nil { - // Debug, not error: a missing cache is a degraded mode, not a failure, - // and at info level a down Redis would drown the log at request rate. - // CacheHealthy() is what surfaces the condition. - slog.Debug("cache write failed", slog.String("error", err.Error())) - } - - return isValid, sanitizedCPF, message, false -} - -// ValidateCPF checks the format of a CPF number. -func ValidateCPF(cpf string) (bool, string, string) { +// checkCPF checks the format of a CPF number. +func checkCPF(cpf string) (bool, string, string) { if !cpfChars.MatchString(cpf) { return false, cpf, "Invalid CPF format (unexpected characters)" } @@ -160,12 +74,12 @@ func isValidCPF(cpf string) bool { return true } -// ValidateCNPJ checks the format and both check digits of a CNPJ. +// checkCNPJ checks the format and both check digits of a CNPJ. // // Ported from the shared TypeScript validators so the two cannot disagree; the // weights and the "remainder below 2 means zero" rule come from the Receita // Federal specification. -func ValidateCNPJ(cnpj string) (bool, string, string) { +func checkCNPJ(cnpj string) (bool, string, string) { if !cnpjChars.MatchString(cnpj) { return false, cnpj, "Invalid CNPJ format (unexpected characters)" } @@ -192,8 +106,8 @@ func ValidateCNPJ(cnpj string) (bool, string, string) { return true, sanitizedCNPJ, "Valid CNPJ format" } -// ValidateEmail validates if the email format is correct. -func ValidateEmail(email string) (bool, string, string) { +// checkEmail validates if the email format is correct. +func checkEmail(email string) (bool, string, string) { sanitizedEmail := strings.TrimSpace(strings.ToLower(email)) if emailRegex.MatchString(sanitizedEmail) { return true, sanitizedEmail, "Valid email format" @@ -201,8 +115,8 @@ func ValidateEmail(email string) (bool, string, string) { return false, sanitizedEmail, "Invalid email format" } -// ValidateRG validates the format of a Brazilian RG (Registro Geral). -func ValidateRG(rg string) (bool, string, string) { +// checkRG validates the format of a Brazilian RG (Registro Geral). +func checkRG(rg string) (bool, string, string) { if !rgChars.MatchString(rg) { return false, rg, "Invalid RG format (unexpected characters)" } @@ -216,8 +130,8 @@ func ValidateRG(rg string) (bool, string, string) { return true, sanitizedRG, "Valid RG format" } -// ValidateCEP validates a Brazilian postal code (CEP). -func ValidateCEP(cep string) (bool, string, string) { +// checkCEP validates a Brazilian postal code (CEP). +func checkCEP(cep string) (bool, string, string) { if !cepChars.MatchString(cep) { return false, cep, "Invalid CEP format (unexpected characters)" } @@ -235,8 +149,8 @@ func ValidateCEP(cep string) (bool, string, string) { return true, sanitizedCEP, "Valid CEP format" } -// ValidateName sanitizes and validates the name format. -func ValidateName(name string) (bool, string, string) { +// checkName sanitizes and validates the name format. +func checkName(name string) (bool, string, string) { rawName := name if !nameChars.MatchString(name) { return false, rawName, "Invalid name format (unexpected characters)" @@ -275,8 +189,8 @@ func removeAccents(input string) string { return output.String() } -// ValidatePhone validates and sanitizes Brazilian phone numbers. -func ValidatePhone(phone string) (bool, string, string) { +// checkPhone validates and sanitizes Brazilian phone numbers. +func checkPhone(phone string) (bool, string, string) { if !phoneChars.MatchString(phone) { return false, phone, "Invalid phone format (unexpected characters)" } @@ -307,8 +221,8 @@ func ValidatePhone(phone string) (bool, string, string) { return true, sanitizedPhone, "Valid phone format" } -// ValidatePlastic validates a credit card number using the Luhn Algorithm and determines its brand. -func ValidatePlastic(cardNumber string) (bool, string, string) { +// checkPlastic validates a credit card number using the Luhn Algorithm and determines its brand. +func checkPlastic(cardNumber string) (bool, string, string) { if !cardChars.MatchString(cardNumber) { return false, cardNumber, "Invalid credit card number (unexpected characters)" } @@ -323,12 +237,12 @@ func ValidatePlastic(cardNumber string) (bool, string, string) { return false, sanitizedCardNumber, "Invalid credit card number" } - brand := GetCardBrand(sanitizedCardNumber) + brand := cardBrandOf(sanitizedCardNumber) return true, sanitizedCardNumber, "Valid credit card number (" + brand + ")" } -// GetCardBrand identifies the card brand based on the BIN. -func GetCardBrand(cardNumber string) string { +// cardBrandOf identifies the card brand based on the BIN. +func cardBrandOf(cardNumber string) string { cardNumber = strings.ReplaceAll(cardNumber, " ", "") // Remove spaces if len(cardNumber) >= 2 { diff --git a/utils/validators_test.go b/validate/core_test.go similarity index 70% rename from utils/validators_test.go rename to validate/core_test.go index 7946427..58388a9 100644 --- a/utils/validators_test.go +++ b/validate/core_test.go @@ -1,4 +1,4 @@ -package utils +package validate import "testing" @@ -16,7 +16,7 @@ func TestValidateEmail(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - isValid, sanitized, _ := ValidateEmail(tt.email) + isValid, sanitized, _ := checkEmail(tt.email) if isValid != tt.valid { t.Fatalf("expected %v, got %v", tt.valid, isValid) } @@ -43,7 +43,7 @@ func TestValidatePhone(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - isValid, sanitized, _ := ValidatePhone(tt.phone) + isValid, sanitized, _ := checkPhone(tt.phone) if isValid != tt.valid { t.Fatalf("expected %v, got %v", tt.valid, isValid) } @@ -55,7 +55,7 @@ func TestValidatePhone(t *testing.T) { } func TestValidateName(t *testing.T) { - isValid, sanitized, _ := ValidateName(" João da Silva ") + isValid, sanitized, _ := checkName(" João da Silva ") if !isValid { t.Fatal("expected valid name") } @@ -65,19 +65,19 @@ func TestValidateName(t *testing.T) { } func TestValidateCPF(t *testing.T) { - valid, _, _ := ValidateCPF("529.982.247-25") + valid, _, _ := checkCPF("529.982.247-25") if !valid { t.Fatal("expected valid cpf") } - invalid, _, _ := ValidateCPF("111.111.111-11") + invalid, _, _ := checkCPF("111.111.111-11") if invalid { t.Fatal("expected invalid cpf") } } func TestValidateCEP(t *testing.T) { - valid, sanitized, _ := ValidateCEP("01310-100") + valid, sanitized, _ := checkCEP("01310-100") if !valid { t.Fatal("expected valid cep") } @@ -85,7 +85,7 @@ func TestValidateCEP(t *testing.T) { t.Fatalf("expected sanitized 01310100, got %q", sanitized) } - invalid, _, _ := ValidateCEP("00000-000") + invalid, _, _ := checkCEP("00000-000") if invalid { t.Fatal("expected invalid cep for all zeroes") } @@ -99,14 +99,14 @@ func TestStrayCharactersAreRejected(t *testing.T) { name string check func() (bool, string, string) }{ - {"cpf_trailing_letters", func() (bool, string, string) { return ValidateCPF("529.982.247-25jasasas") }}, - {"cpf_leading_letters", func() (bool, string, string) { return ValidateCPF("abc529.982.247-25") }}, - {"cpf_foreign_symbol", func() (bool, string, string) { return ValidateCPF("529.982.247-25/") }}, - {"rg_trailing_letters", func() (bool, string, string) { return ValidateRG("12.345.678abc") }}, - {"cep_trailing_letters", func() (bool, string, string) { return ValidateCEP("70040-010abc") }}, - {"phone_trailing_letters", func() (bool, string, string) { return ValidatePhone("(11) 98765-4321abc") }}, - {"card_trailing_letters", func() (bool, string, string) { return ValidatePlastic("4111111111111111zz") }}, - {"name_with_digits", func() (bool, string, string) { return ValidateName("Jeferson123 Siqueira") }}, + {"cpf_trailing_letters", func() (bool, string, string) { return checkCPF("529.982.247-25jasasas") }}, + {"cpf_leading_letters", func() (bool, string, string) { return checkCPF("abc529.982.247-25") }}, + {"cpf_foreign_symbol", func() (bool, string, string) { return checkCPF("529.982.247-25/") }}, + {"rg_trailing_letters", func() (bool, string, string) { return checkRG("12.345.678abc") }}, + {"cep_trailing_letters", func() (bool, string, string) { return checkCEP("70040-010abc") }}, + {"phone_trailing_letters", func() (bool, string, string) { return checkPhone("(11) 98765-4321abc") }}, + {"card_trailing_letters", func() (bool, string, string) { return checkPlastic("4111111111111111zz") }}, + {"name_with_digits", func() (bool, string, string) { return checkName("Jeferson123 Siqueira") }}, } for _, tt := range tests { @@ -125,15 +125,15 @@ func TestLegitimateFormattingStillPasses(t *testing.T) { name string check func() (bool, string, string) }{ - {"cpf_dotted", func() (bool, string, string) { return ValidateCPF("529.982.247-25") }}, - {"cpf_spaced", func() (bool, string, string) { return ValidateCPF("529 982 247 25") }}, - {"cpf_bare", func() (bool, string, string) { return ValidateCPF("52998224725") }}, - {"rg_dotted", func() (bool, string, string) { return ValidateRG("12.345.678") }}, - {"cep_dashed", func() (bool, string, string) { return ValidateCEP("70040-010") }}, - {"phone_parenthesised", func() (bool, string, string) { return ValidatePhone("(11) 98765-4321") }}, - {"phone_country_code", func() (bool, string, string) { return ValidatePhone("+55 11 98765-4321") }}, - {"card_spaced", func() (bool, string, string) { return ValidatePlastic("4111 1111 1111 1111") }}, - {"name_accented", func() (bool, string, string) { return ValidateName("Jeférson D'Ávila Siqueira") }}, + {"cpf_dotted", func() (bool, string, string) { return checkCPF("529.982.247-25") }}, + {"cpf_spaced", func() (bool, string, string) { return checkCPF("529 982 247 25") }}, + {"cpf_bare", func() (bool, string, string) { return checkCPF("52998224725") }}, + {"rg_dotted", func() (bool, string, string) { return checkRG("12.345.678") }}, + {"cep_dashed", func() (bool, string, string) { return checkCEP("70040-010") }}, + {"phone_parenthesised", func() (bool, string, string) { return checkPhone("(11) 98765-4321") }}, + {"phone_country_code", func() (bool, string, string) { return checkPhone("+55 11 98765-4321") }}, + {"card_spaced", func() (bool, string, string) { return checkPlastic("4111 1111 1111 1111") }}, + {"name_accented", func() (bool, string, string) { return checkName("Jeférson D'Ávila Siqueira") }}, } for _, tt := range tests { @@ -149,7 +149,7 @@ func TestLegitimateFormattingStillPasses(t *testing.T) { // A rejected value is echoed back untouched: it was never accepted, so // presenting a "sanitised" version of it would be misleading. func TestRejectedValueIsEchoedRaw(t *testing.T) { - _, value, _ := ValidateCPF("abc529.982.247-25") + _, value, _ := checkCPF("abc529.982.247-25") if value != "abc529.982.247-25" { t.Fatalf("expected the raw input back, got %q", value) } diff --git a/utils/documents.go b/validate/documents.go similarity index 86% rename from utils/documents.go rename to validate/documents.go index 292a426..96a5166 100644 --- a/utils/documents.go +++ b/validate/documents.go @@ -1,4 +1,4 @@ -package utils +package validate import ( "regexp" @@ -24,11 +24,11 @@ var ( pixPhone = regexp.MustCompile(`^\+55\d{10,11}$`) ) -// ValidatePIS checks a PIS/PASEP/NIT/NIS number. +// checkPIS checks a PIS/PASEP/NIT/NIS number. // // All four names refer to the same eleven-digit number with the same check // digit, so one validator covers them; the API exposes it as "pis". -func ValidatePIS(pis string) (bool, string, string) { +func checkPIS(pis string) (bool, string, string) { if !pisChars.MatchString(pis) { return false, pis, "Invalid PIS format (unexpected characters)" } @@ -49,7 +49,7 @@ func ValidatePIS(pis string) (bool, string, string) { return true, sanitized, "Valid PIS format" } -// ValidateTituloEleitor checks a voter registration number. +// checkTituloEleitor checks a voter registration number. // // The number is an eight-digit sequence, two digits identifying the issuing // state, and two check digits. Three details make it unlike the other @@ -60,7 +60,7 @@ func ValidatePIS(pis string) (bool, string, string) { // their numbers carry thirteen digits rather than twelve. The state code is // therefore located from the end, never at a fixed offset. // - For those same two states a remainder of zero yields 1, not 0. -func ValidateTituloEleitor(titulo string) (bool, string, string) { +func checkTituloEleitor(titulo string) (bool, string, string) { if !tituloChars.MatchString(titulo) { return false, titulo, "Invalid voter ID format (unexpected characters)" } @@ -113,9 +113,9 @@ func voterDigit(sum int, extendedState bool) int { return remainder } -// ValidatePlate checks a vehicle plate in either the old or the Mercosul +// checkPlate checks a vehicle plate in either the old or the Mercosul // pattern. There is no check digit — a plate is a format, not a computation. -func ValidatePlate(plate string) (bool, string, string) { +func checkPlate(plate string) (bool, string, string) { if !plateChars.MatchString(plate) { return false, plate, "Invalid plate format (unexpected characters)" } @@ -132,22 +132,22 @@ func ValidatePlate(plate string) (bool, string, string) { } } -// ValidateDocument accepts whichever of CPF or CNPJ the digits describe. +// checkDocument accepts whichever of CPF or CNPJ the digits describe. // // Forms routinely offer a single "document" field, and asking the caller to // decide which endpoint to call defeats the point of that field. -func ValidateDocument(document string) (bool, string, string) { +func checkDocument(document string) (bool, string, string) { digits := nonDigitsRegex.ReplaceAllString(document, "") switch len(digits) { case 11: - isValid, sanitized, _ := ValidateCPF(document) + isValid, sanitized, _ := checkCPF(document) if isValid { return true, sanitized, "Valid document (CPF)" } return false, sanitized, "Invalid document (CPF check failed)" case 14: - isValid, sanitized, _ := ValidateCNPJ(document) + isValid, sanitized, _ := checkCNPJ(document) if isValid { return true, sanitized, "Valid document (CNPJ)" } @@ -157,12 +157,12 @@ func ValidateDocument(document string) (bool, string, string) { } } -// ValidatePixKey checks a PIX key in any of the five forms the arrangement +// checkPixKey checks a PIX key in any of the five forms the arrangement // defines: CPF, CNPJ, email, phone in E.164, or a random UUID. // // The form is inferred rather than asked for, because that is how a key arrives // — pasted into one field, with nothing saying which kind it is. -func ValidatePixKey(key string) (bool, string, string) { +func checkPixKey(key string) (bool, string, string) { trimmed := strings.TrimSpace(key) if trimmed == "" { return false, key, "Invalid PIX key (empty)" @@ -175,7 +175,7 @@ func ValidatePixKey(key string) (bool, string, string) { return true, trimmed, "Valid PIX key (phone)" } if strings.Contains(trimmed, "@") { - if isValid, sanitized, _ := ValidateEmail(trimmed); isValid { + if isValid, sanitized, _ := checkEmail(trimmed); isValid { return true, sanitized, "Valid PIX key (email)" } return false, trimmed, "Invalid PIX key (malformed email)" @@ -184,12 +184,12 @@ func ValidatePixKey(key string) (bool, string, string) { digits := nonDigitsRegex.ReplaceAllString(trimmed, "") switch len(digits) { case 11: - if isValid, sanitized, _ := ValidateCPF(trimmed); isValid { + if isValid, sanitized, _ := checkCPF(trimmed); isValid { return true, sanitized, "Valid PIX key (CPF)" } return false, digits, "Invalid PIX key (CPF check failed)" case 14: - if isValid, sanitized, _ := ValidateCNPJ(trimmed); isValid { + if isValid, sanitized, _ := checkCNPJ(trimmed); isValid { return true, sanitized, "Valid PIX key (CNPJ)" } return false, digits, "Invalid PIX key (CNPJ check failed)" diff --git a/utils/documents_extra_test.go b/validate/documents_extra_test.go similarity index 94% rename from utils/documents_extra_test.go rename to validate/documents_extra_test.go index 6d2daa9..6563b55 100644 --- a/utils/documents_extra_test.go +++ b/validate/documents_extra_test.go @@ -1,4 +1,4 @@ -package utils +package validate import "testing" @@ -26,7 +26,7 @@ func TestValidatePlate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - isValid, _, message := ValidatePlate(tt.plate) + isValid, _, message := checkPlate(tt.plate) if isValid != tt.valid { t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) } @@ -52,7 +52,7 @@ func TestValidateDocument(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - isValid, _, message := ValidateDocument(tt.document) + isValid, _, message := checkDocument(tt.document) if isValid != tt.valid { t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) } @@ -87,7 +87,7 @@ func TestValidatePixKey(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - isValid, _, message := ValidatePixKey(tt.key) + isValid, _, message := checkPixKey(tt.key) if isValid != tt.valid { t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) } diff --git a/utils/documents_test.go b/validate/documents_test.go similarity index 94% rename from utils/documents_test.go rename to validate/documents_test.go index 0f24f83..e425220 100644 --- a/utils/documents_test.go +++ b/validate/documents_test.go @@ -1,4 +1,4 @@ -package utils +package validate import ( "fmt" @@ -28,7 +28,7 @@ func TestValidatePISKnownVectors(t *testing.T) { for _, tt := range tests { t.Run(tt.pis, func(t *testing.T) { - isValid, _, message := ValidatePIS(tt.pis) + isValid, _, message := checkPIS(tt.pis) if isValid != tt.valid { t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) } @@ -52,7 +52,7 @@ func TestValidateTituloEleitor(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - isValid, _, message := ValidateTituloEleitor(tt.value) + isValid, _, message := checkTituloEleitor(tt.value) if isValid != tt.valid { t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) } @@ -135,7 +135,7 @@ func TestPISAgreesWithReference(t *testing.T) { for i := 0; i < 5000; i++ { candidate := randomDigits(random, 11) - got, _, _ := ValidatePIS(candidate) + got, _, _ := checkPIS(candidate) if want := referencePIS(candidate); got != want { t.Fatalf("PIS %s: got %v, reference says %v", candidate, got, want) @@ -152,7 +152,7 @@ func TestVoterIDAgreesWithReference(t *testing.T) { length = 13 } candidate := randomDigits(random, length) - got, _, _ := ValidateTituloEleitor(candidate) + got, _, _ := checkTituloEleitor(candidate) if want := referenceVoterID(candidate); got != want { t.Fatalf("voter ID %s: got %v, reference says %v", candidate, got, want) @@ -168,7 +168,7 @@ func TestConstructedNumbersAreAccepted(t *testing.T) { for i := 0; i < 500; i++ { pis := constructPIS(randomDigits(random, 10)) - if isValid, _, message := ValidatePIS(pis); !isValid { + if isValid, _, message := checkPIS(pis); !isValid { t.Fatalf("constructed PIS %s was rejected: %s", pis, message) } if !referencePIS(pis) { diff --git a/validate/example_test.go b/validate/example_test.go new file mode 100644 index 0000000..fb08e71 --- /dev/null +++ b/validate/example_test.go @@ -0,0 +1,82 @@ +package validate_test + +import ( + "fmt" + + "github.com/jeferson0306/api-data-validator/validate" +) + +func ExampleCPF() { + result := validate.CPF("529.982.247-25") + + fmt.Println(result.Valid) + fmt.Println(result.Normalized) + // Output: + // true + // 52998224725 +} + +// Formatting a person types is accepted and removed. Anything else is a +// rejection — the letters here are not stripped and ignored. +func ExampleCPF_strayCharacters() { + fmt.Println(validate.CPF("529.982.247-25").Valid) + fmt.Println(validate.CPF("abc529.982.247-25").Valid) + // Output: + // true + // false +} + +// A rejected value comes back untouched: it was never accepted, so presenting a +// cleaned-up version of it would be misleading. +func ExampleResult_normalized() { + fmt.Printf("%q\n", validate.CEP("70040-010").Normalized) + fmt.Printf("%q\n", validate.CEP("70040-010abc").Normalized) + // Output: + // "70040010" + // "70040-010abc" +} + +// The single "document" field most forms offer, taking either a CPF or a CNPJ. +func ExampleDocument() { + fmt.Println(validate.Document("529.982.247-25").Reason) + fmt.Println(validate.Document("33.000.167/0001-01").Reason) + // Output: + // Valid document (CPF) + // Valid document (CNPJ) +} + +// A PIX key arrives pasted into one box with nothing saying which of the five +// forms it is, so the form is inferred. +func ExamplePixKey() { + for _, key := range []string{ + "529.982.247-25", + "jeferson@example.com", + "+5511987654321", + "123e4567-e89b-12d3-a456-426614174000", + } { + fmt.Println(validate.PixKey(key).Reason) + } + // Output: + // Valid PIX key (CPF) + // Valid PIX key (email) + // Valid PIX key (phone) + // Valid PIX key (random) +} + +// The state is required: the same digits can be valid in one state and nonsense +// in another. +func ExampleStateRegistration() { + fmt.Println(validate.StateRegistration("0100482300112", "AC").Valid) + fmt.Println(validate.StateRegistration("0100482300112", "SP").Valid) + // Output: + // true + // false +} + +// For callers that learn the document type at runtime. +func ExampleByKey() { + result, known := validate.ByKey("cnpj", "33.000.167/0001-01", "") + + fmt.Println(known, result.Valid) + // Output: true true +} diff --git a/utils/ie_arithmetic.go b/validate/ie_arithmetic.go similarity index 99% rename from utils/ie_arithmetic.go rename to validate/ie_arithmetic.go index ae07924..ce19a78 100644 --- a/utils/ie_arithmetic.go +++ b/validate/ie_arithmetic.go @@ -1,4 +1,4 @@ -package utils +package validate import "strings" diff --git a/utils/ie_states.go b/validate/ie_states.go similarity index 99% rename from utils/ie_states.go rename to validate/ie_states.go index eda82f0..ff3aea7 100644 --- a/utils/ie_states.go +++ b/validate/ie_states.go @@ -1,4 +1,4 @@ -package utils +package validate import "strconv" diff --git a/utils/inscricao_estadual.go b/validate/inscricao_estadual.go similarity index 85% rename from utils/inscricao_estadual.go rename to validate/inscricao_estadual.go index 1408863..85fc4fc 100644 --- a/utils/inscricao_estadual.go +++ b/validate/inscricao_estadual.go @@ -1,4 +1,4 @@ -package utils +package validate import ( "regexp" @@ -12,9 +12,9 @@ import ( var ieChars = regexp.MustCompile(`^[\dPp.\-/\s]*$`) -// SupportedIEStates lists the states this service can check, sorted, for the +// supportedStates lists the states this service can check, sorted, for the // error message when an unknown one arrives. -func SupportedIEStates() []string { +func supportedStates() []string { states := make([]string, 0, len(ieValidators)) for uf := range ieValidators { states = append(states, uf) @@ -23,13 +23,13 @@ func SupportedIEStates() []string { return states } -// ValidateInscricaoEstadual checks a state registration against the roteiro de +// checkInscricaoEstadual checks a state registration against the roteiro de // crítica published by the issuing state. // // The state is a required argument, not an inference. A registration carries no // marker of where it came from, so a service that guessed would be answering a // question it was not asked. -func ValidateInscricaoEstadual(ie, uf string) (bool, string, string) { +func checkInscricaoEstadual(ie, uf string) (bool, string, string) { // "Isento" — exempt — is a legitimate value in every state for a business // that is registered but has no number assigned. It has to be recognised // before the character guard, which allows only digits and formatting. @@ -48,7 +48,7 @@ func ValidateInscricaoEstadual(ie, uf string) (bool, string, string) { validate, supported := ieValidators[state] if !supported { - return false, ie, "Unknown state " + state + " (expected one of " + strings.Join(SupportedIEStates(), ", ") + ")" + return false, ie, "Unknown state " + state + " (expected one of " + strings.Join(supportedStates(), ", ") + ")" } // São Paulo's rural registrations open with a P; every other character that diff --git a/utils/inscricao_estadual_test.go b/validate/inscricao_estadual_test.go similarity index 84% rename from utils/inscricao_estadual_test.go rename to validate/inscricao_estadual_test.go index 8e3dfae..97e1de8 100644 --- a/utils/inscricao_estadual_test.go +++ b/validate/inscricao_estadual_test.go @@ -1,4 +1,4 @@ -package utils +package validate import "testing" @@ -34,7 +34,7 @@ func TestInscricaoEstadualOfficialExamples(t *testing.T) { for uf, ie := range official { t.Run(uf, func(t *testing.T) { - isValid, _, message := ValidateInscricaoEstadual(ie, uf) + isValid, _, message := checkInscricaoEstadual(ie, uf) if !isValid { t.Fatalf("%s rejected its own published example %s: %s", uf, ie, message) } @@ -47,7 +47,7 @@ func TestInscricaoEstadualOfficialExamples(t *testing.T) { // was checked against gets wrong, which is why it is called out separately. func TestInscricaoEstadualBahiaBothLengths(t *testing.T) { for _, ie := range []string{"12345663", "100000306"} { - if isValid, _, message := ValidateInscricaoEstadual(ie, "BA"); !isValid { + if isValid, _, message := checkInscricaoEstadual(ie, "BA"); !isValid { t.Fatalf("BA rejected its published example %s: %s", ie, message) } } @@ -70,7 +70,7 @@ func TestInscricaoEstadualRejections(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if isValid, _, message := ValidateInscricaoEstadual(tt.ie, tt.uf); isValid { + if isValid, _, message := checkInscricaoEstadual(tt.ie, tt.uf); isValid { t.Fatalf("expected rejection, got valid (%s)", message) } }) @@ -81,14 +81,14 @@ func TestInscricaoEstadualExempt(t *testing.T) { // A business can be registered and exempt from holding a number; a // validator that rejected "ISENTO" would be rejecting a legal answer. for _, value := range []string{"ISENTO", "isento", " Isento "} { - if isValid, sanitized, _ := ValidateInscricaoEstadual(value, "SP"); !isValid || sanitized != "ISENTO" { + if isValid, sanitized, _ := checkInscricaoEstadual(value, "SP"); !isValid || sanitized != "ISENTO" { t.Fatalf("expected %q to be accepted as exempt, got valid=%v value=%q", value, isValid, sanitized) } } } func TestSupportedStatesCoverTheFederation(t *testing.T) { - if got := len(SupportedIEStates()); got != 27 { - t.Fatalf("expected all 27 states, got %d: %v", got, SupportedIEStates()) + if got := len(supportedStates()); got != 27 { + t.Fatalf("expected all 27 states, got %d: %v", got, supportedStates()) } } diff --git a/validate/validate.go b/validate/validate.go new file mode 100644 index 0000000..ee1557c --- /dev/null +++ b/validate/validate.go @@ -0,0 +1,118 @@ +// Package validate checks Brazilian documents. +// +// Everything here is pure: a function takes a string, returns an answer, and +// touches nothing else. There is no network, no cache, no configuration and no +// state — importing this package opens no connections and reads no environment. +// That is deliberate. Validation is arithmetic, and arithmetic that needs a +// server is arithmetic you cannot trust when the server is down. +// +// Values are checked, not laundered. Formatting a person legitimately types — +// dots, dashes, spaces, the slash in a CNPJ — is accepted and removed; anything +// else is a rejection. Stripping unexpected characters and validating what +// remains would accept "abc529.982.247-25" as a CPF, which is how a validator +// ends up letting junk into a database. +// +// result := validate.CPF("529.982.247-25") +// result.Valid // true +// result.Normalized // "52998224725" +// +// For callers that do not know the document type at compile time — a form +// driven by configuration, say — [ByKey] dispatches on a string, and [Keys] +// lists what it accepts. +package validate + +// Result is the answer to one check. +type Result struct { + // Valid reports whether the value passed. + Valid bool + + // Normalized is the value reduced to what a database should store: + // digits only for most documents, lower-cased for an email, upper-cased and + // unpunctuated for a plate. When a value is rejected outright it is + // returned untouched, because presenting a cleaned-up version of something + // that was never accepted is misleading. + Normalized string + + // Reason explains the outcome in English, whether it passed or failed. It + // is meant for a log or a developer, not for an end user — a message shown + // to a person should be written by the application, in its own voice and + // language. + Reason string +} + +func result(valid bool, normalized, reason string) Result { + return Result{Valid: valid, Normalized: normalized, Reason: reason} +} + +// CPF checks an individual taxpayer number, including both check digits. +func CPF(value string) Result { return result(checkCPF(value)) } + +// CNPJ checks a company taxpayer number, including both check digits. +func CNPJ(value string) Result { return result(checkCNPJ(value)) } + +// Document accepts whichever of CPF or CNPJ the digit count describes, for the +// single "documento" field forms usually offer. +func Document(value string) Result { return result(checkDocument(value)) } + +// PIS checks a PIS, PASEP, NIT or NIS number — four names for one number. +func PIS(value string) Result { return result(checkPIS(value)) } + +// VoterID checks a título de eleitor, including the issuing state encoded in it. +func VoterID(value string) Result { return result(checkTituloEleitor(value)) } + +// CNH checks a driving licence number. Its two check digits are coupled: when +// the first overflows it is written as zero and the second compensates. +func CNH(value string) Result { return result(checkCNH(value)) } + +// Renavam checks a vehicle registration number in either the nine-digit or the +// eleven-digit form, normalising both to eleven. +func Renavam(value string) Result { return result(checkRenavam(value)) } + +// Plate checks a vehicle plate in the pre-Mercosul or Mercosul pattern. +func Plate(value string) Result { return result(checkPlate(value)) } + +// Boleto checks the 47-digit linha digitável of a bank slip: the three typed +// groups and then the barcode's own check digit. +func Boleto(value string) Result { return result(checkBoleto(value)) } + +// PixKey checks a PIX key in any of its five forms — CPF, CNPJ, email, phone in +// E.164, or a random UUID — inferring which it is. +func PixKey(value string) Result { return result(checkPixKey(value)) } + +// StateRegistration checks an inscrição estadual against the rules of the state +// that issued it. The state is required: the same digits can be valid in one +// state and nonsense in another. +func StateRegistration(value, uf string) Result { + return result(checkInscricaoEstadual(value, uf)) +} + +// Email checks the address format and normalises its case. +func Email(value string) Result { return result(checkEmail(value)) } + +// FullName checks that a name is a plausible full name and normalises it. +func FullName(value string) Result { return result(checkName(value)) } + +// Phone checks a Brazilian landline or mobile number, area code included. +func Phone(value string) Result { return result(checkPhone(value)) } + +// Card checks a card number with the Luhn checksum and identifies the brand. +func Card(value string) Result { return result(checkPlastic(value)) } + +// CardBrand names the brand of a card number, or an empty string when no +// pattern matches. It does not validate — use [Card] for that. +func CardBrand(value string) string { + if brand := cardBrandOf(value); brand != "Unknown" { + return brand + } + return "" +} + +// RG checks the format of a state identity number. There is no national +// checksum, so this is a shape check and nothing more. +func RG(value string) Result { return result(checkRG(value)) } + +// CEP checks a postal code. +func CEP(value string) Result { return result(checkCEP(value)) } + +// States lists the state codes [StateRegistration] can check, sorted. +func States() []string { return supportedStates() } diff --git a/utils/vehicle.go b/validate/vehicle.go similarity index 91% rename from utils/vehicle.go rename to validate/vehicle.go index 1496116..29ce8a3 100644 --- a/utils/vehicle.go +++ b/validate/vehicle.go @@ -1,4 +1,4 @@ -package utils +package validate import "regexp" @@ -7,13 +7,13 @@ var ( renavamChars = regexp.MustCompile(`^[\d.\-\s]*$`) ) -// ValidateCNH checks a driving licence number. +// checkCNH checks a driving licence number. // // The two check digits are unusual in being coupled: when the first overflows // past nine it is written as zero, and the second is then computed two lower to // compensate. Treating them independently — which is what most of the wrong // implementations of this do — accepts numbers the Detran would reject. -func ValidateCNH(cnh string) (bool, string, string) { +func checkCNH(cnh string) (bool, string, string) { if !cnhChars.MatchString(cnh) { return false, cnh, "Invalid CNH format (unexpected characters)" } @@ -59,13 +59,13 @@ func ValidateCNH(cnh string) (bool, string, string) { return true, digits, "Valid CNH format" } -// ValidateRenavam checks a vehicle registration number. +// checkRenavam checks a vehicle registration number. // // Nine digits was the old format and eleven is the current one; the shorter is // the longer with leading zeros, so both are accepted and normalised to eleven. // The weights run from the right and cycle two through nine, unlike the // left-to-right descending series most Brazilian documents use. -func ValidateRenavam(renavam string) (bool, string, string) { +func checkRenavam(renavam string) (bool, string, string) { if !renavamChars.MatchString(renavam) { return false, renavam, "Invalid RENAVAM format (unexpected characters)" } diff --git a/utils/vehicle_boleto_differential_test.go b/validate/vehicle_boleto_differential_test.go similarity index 93% rename from utils/vehicle_boleto_differential_test.go rename to validate/vehicle_boleto_differential_test.go index 16271a0..5e47c99 100644 --- a/utils/vehicle_boleto_differential_test.go +++ b/validate/vehicle_boleto_differential_test.go @@ -1,4 +1,4 @@ -package utils +package validate import ( "math/rand" @@ -79,7 +79,7 @@ func TestCNHAgreesWithReference(t *testing.T) { for i := 0; i < 5000; i++ { candidate := randomDigits(random, 11) - got, _, _ := ValidateCNH(candidate) + got, _, _ := checkCNH(candidate) if want := referenceCNH(candidate); got != want { t.Fatalf("CNH %s: got %v, reference says %v", candidate, got, want) @@ -92,7 +92,7 @@ func TestRenavamAgreesWithReference(t *testing.T) { for i := 0; i < 5000; i++ { candidate := randomDigits(random, 11) - got, _, _ := ValidateRenavam(candidate) + got, _, _ := checkRenavam(candidate) // The one deliberate deviation: a run of one repeated digit is not a // registration, and this service rejects it where the reference does not. @@ -137,7 +137,7 @@ func TestConstructedVehicleNumbersAreAccepted(t *testing.T) { if allSameDigit(cnh) { continue } - if isValid, _, message := ValidateCNH(cnh); !isValid { + if isValid, _, message := checkCNH(cnh); !isValid { t.Fatalf("constructed CNH %s was rejected: %s", cnh, message) } } @@ -149,7 +149,7 @@ func TestConstructedVehicleNumbersAreAccepted(t *testing.T) { func TestConstructedBoletoIsAcceptedAndSensitiveToEveryDigit(t *testing.T) { valid := "00190000090114971860168524522114675860000102656" - if isValid, _, message := ValidateBoleto(valid); !isValid { + if isValid, _, message := checkBoleto(valid); !isValid { t.Fatalf("the published example was rejected: %s", message) } @@ -163,7 +163,7 @@ func TestConstructedBoletoIsAcceptedAndSensitiveToEveryDigit(t *testing.T) { mutated[i]++ } - if isValid, _, _ := ValidateBoleto(string(mutated)); isValid { + if isValid, _, _ := checkBoleto(string(mutated)); isValid { t.Fatalf("changing digit %d left the boleto valid: %s", i, string(mutated)) } } diff --git a/utils/vehicle_boleto_test.go b/validate/vehicle_boleto_test.go similarity index 91% rename from utils/vehicle_boleto_test.go rename to validate/vehicle_boleto_test.go index f21e1db..cf9e14a 100644 --- a/utils/vehicle_boleto_test.go +++ b/validate/vehicle_boleto_test.go @@ -1,4 +1,4 @@ -package utils +package validate import "testing" @@ -19,7 +19,7 @@ func TestValidateCNH(t *testing.T) { for _, tt := range tests { t.Run(tt.cnh, func(t *testing.T) { - isValid, _, message := ValidateCNH(tt.cnh) + isValid, _, message := checkCNH(tt.cnh) if isValid != tt.valid { t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) } @@ -49,7 +49,7 @@ func TestValidateRenavam(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - isValid, _, message := ValidateRenavam(tt.renavam) + isValid, _, message := checkRenavam(tt.renavam) if isValid != tt.valid { t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) } @@ -60,8 +60,8 @@ func TestValidateRenavam(t *testing.T) { // The nine-digit form is the eleven-digit form with leading zeros, so both must // normalise to the same stored value. func TestRenavamNormalisesToElevenDigits(t *testing.T) { - _, short, _ := ValidateRenavam("639884962") - _, long, _ := ValidateRenavam("00639884962") + _, short, _ := checkRenavam("639884962") + _, long, _ := checkRenavam("00639884962") if short != long || short != "00639884962" { t.Fatalf("expected both forms to normalise to 00639884962, got %q and %q", short, long) @@ -85,7 +85,7 @@ func TestValidateBoleto(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - isValid, _, message := ValidateBoleto(tt.boleto) + isValid, _, message := checkBoleto(tt.boleto) if isValid != tt.valid { t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) } @@ -99,7 +99,7 @@ func TestValidateBoleto(t *testing.T) { func TestBoletoArrecadacaoIsReportedAsUnchecked(t *testing.T) { arrecadacao := "846700000017435900240200610207807116000000000000" - isValid, _, message := ValidateBoleto(arrecadacao) + isValid, _, message := checkBoleto(arrecadacao) if isValid { t.Fatal("a 48-digit slip must not be reported as valid") }