diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aff3292 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [master] + +jobs: + verify: + name: Vet, test and build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Check formatting + # gofmt -l prints the files that need formatting; anything printed fails. + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt'd:" + echo "$unformatted" + exit 1 + fi + + - run: go vet ./... + + # -race because the cache and the HTTP handlers run concurrently, and a + # data race there would surface as a wrong validation result under load. + - run: go test ./... -race -coverprofile=coverage.out + + - run: go build ./... + + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -1 diff --git a/.gitignore b/.gitignore index 290c934..0e60d56 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,7 @@ docker-compose.override.yml Thumbs.db -vcs.xml \ No newline at end of file +vcs.xml +# JetBrains project files are per-developer, not per-project. +.idea/ +*.iml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/api-data-validator.iml b/.idea/api-data-validator.iml deleted file mode 100644 index 5e764c4..0000000 --- a/.idea/api-data-validator.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/git_toolbox_blame.xml b/.idea/git_toolbox_blame.xml deleted file mode 100644 index 7dc1249..0000000 --- a/.idea/git_toolbox_blame.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index a47a3df..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1524c1b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jeferson Siqueira + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/docs.go b/docs/docs.go index 066b07a..4cc2338 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -35,6 +35,12 @@ const docTemplate = `{ "name": "cpf", "in": "query" }, + { + "type": "string", + "description": "CNPJ to be validated", + "name": "cnpj", + "in": "query" + }, { "type": "string", "description": "Name to be validated", @@ -47,6 +53,12 @@ const docTemplate = `{ "name": "telephone", "in": "query" }, + { + "type": "string", + "description": "Phone number to be validated", + "name": "phone", + "in": "query" + }, { "type": "string", "description": "Credit card number to be validated", @@ -78,6 +90,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/models.ValidationResponse" } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/models.ValidationResponse" + } } } } @@ -87,6 +105,9 @@ const docTemplate = `{ "models.ValidationResponse": { "type": "object", "properties": { + "error_code": { + "type": "string" + }, "execution_time_ms": { "type": "integer" }, diff --git a/docs/swagger.json b/docs/swagger.json index 3f34cfc..f9fcdea 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -24,6 +24,12 @@ "name": "cpf", "in": "query" }, + { + "type": "string", + "description": "CNPJ to be validated", + "name": "cnpj", + "in": "query" + }, { "type": "string", "description": "Name to be validated", @@ -36,6 +42,12 @@ "name": "telephone", "in": "query" }, + { + "type": "string", + "description": "Phone number to be validated", + "name": "phone", + "in": "query" + }, { "type": "string", "description": "Credit card number to be validated", @@ -67,6 +79,12 @@ "schema": { "$ref": "#/definitions/models.ValidationResponse" } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/models.ValidationResponse" + } } } } @@ -76,6 +94,9 @@ "models.ValidationResponse": { "type": "object", "properties": { + "error_code": { + "type": "string" + }, "execution_time_ms": { "type": "integer" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f187983..2192557 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,6 +1,8 @@ definitions: models.ValidationResponse: properties: + error_code: + type: string execution_time_ms: type: integer from_cache: @@ -41,6 +43,10 @@ paths: in: query name: cpf type: string + - description: CNPJ to be validated + in: query + name: cnpj + type: string - description: Name to be validated in: query name: name @@ -49,6 +55,10 @@ paths: in: query name: telephone type: string + - description: Phone number to be validated + in: query + name: phone + type: string - description: Credit card number to be validated in: query name: plastic @@ -70,6 +80,10 @@ paths: description: Bad Request schema: $ref: '#/definitions/models.ValidationResponse' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/models.ValidationResponse' summary: Validates provided data format tags: - Validation diff --git a/handlers/validate.go b/handlers/validate.go index 6dd59c6..463dc07 100644 --- a/handlers/validate.go +++ b/handlers/validate.go @@ -17,6 +17,7 @@ import ( // @Tags Validation // @Param email query string false "Email to be validated" // @Param cpf query string false "CPF to be validated" +// @Param cnpj query string false "CNPJ to be validated" // @Param name query string false "Name to be validated" // @Param telephone query string false "Phone number to be validated" // @Param phone query string false "Phone number to be validated" @@ -51,6 +52,9 @@ func ValidateHandler(w http.ResponseWriter, r *http.Request) { } else if cpf := r.URL.Query().Get("cpf"); cpf != "" { isValid, sanitizedValue, message, fromCache := utils.ValidateCPFWithCache(cpf) response = createResponse("cpf", cpf, sanitizedValue, isValid, message, start, fromCache) + } else if cnpj := r.URL.Query().Get("cnpj"); cnpj != "" { + isValid, sanitizedValue, message := utils.ValidateCNPJ(cnpj) + response = createResponse("cnpj", cnpj, sanitizedValue, isValid, message, start, false) } else if name := r.URL.Query().Get("name"); name != "" { isValid, sanitizedValue, message := utils.ValidateName(name) response = createResponse("name", name, sanitizedValue, isValid, message, start, false) @@ -87,6 +91,9 @@ func validationCount(r *http.Request) int { if strings.TrimSpace(r.URL.Query().Get("email")) != "" { count++ } + if strings.TrimSpace(r.URL.Query().Get("cnpj")) != "" { + count++ + } if strings.TrimSpace(r.URL.Query().Get("cpf")) != "" { count++ } diff --git a/handlers/validate_test.go b/handlers/validate_test.go index 89847a6..0112205 100644 --- a/handlers/validate_test.go +++ b/handlers/validate_test.go @@ -98,3 +98,37 @@ func TestValidateHandlerCPFValidationFailureStatus(t *testing.T) { t.Fatalf("expected VALIDATION_FAILED, got %s", response.ErrorCode) } } + +func TestValidateHandlerCNPJ(t *testing.T) { + tests := []struct { + name string + query string + wantStatus int + wantValid bool + }{ + {"valid_cnpj", "cnpj=33.000.167/0001-01", http.StatusOK, true}, + {"invalid_cnpj", "cnpj=33.000.167/0001-11", http.StatusUnprocessableEntity, false}, + // CNPJ has to take part in the one-parameter-per-request rule, or it + // becomes the hole through which two validations arrive at once. + {"cnpj_with_cpf", "cnpj=33.000.167/0001-01&cpf=529.982.247-25", http.StatusBadRequest, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + ValidateHandler(recorder, httptest.NewRequest(http.MethodGet, "/validate?"+tt.query, nil)) + + if recorder.Code != tt.wantStatus { + t.Fatalf("expected status %d, got %d: %s", tt.wantStatus, recorder.Code, recorder.Body.String()) + } + + var response models.ValidationResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("could not decode the response: %v", err) + } + if response.IsValid != tt.wantValid { + t.Fatalf("expected is_valid=%v, got %v (%s)", tt.wantValid, response.IsValid, response.Message) + } + }) + } +} diff --git a/utils/cnpj_test.go b/utils/cnpj_test.go new file mode 100644 index 0000000..003841f --- /dev/null +++ b/utils/cnpj_test.go @@ -0,0 +1,42 @@ +package utils + +import "testing" + +func TestValidateCNPJ(t *testing.T) { + tests := []struct { + name string + cnpj string + valid bool + sanitized string + }{ + // Petrobras and the Banco do Brasil — real, published CNPJs. + {"formatted", "33.000.167/0001-01", true, "33000167000101"}, + {"bare", "00000000000191", true, "00000000000191"}, + {"spaced", "33 000 167 0001 01", true, "33000167000101"}, + + {"wrong_first_check_digit", "33.000.167/0001-11", false, "33000167000111"}, + {"wrong_second_check_digit", "33.000.167/0001-02", false, "33000167000102"}, + {"too_short", "33.000.167/0001", false, "330001670001"}, + {"too_long", "33.000.167/0001-010", false, "330001670001010"}, + + // Passes the check-digit arithmetic, so it needs excluding explicitly. + {"all_zeros", "00.000.000/0000-00", false, "00000000000000"}, + {"all_ones", "11.111.111/1111-11", false, "11111111111111"}, + + // The guard from the sanitisation fix must cover CNPJ too. + {"trailing_letters", "33.000.167/0001-01abc", false, "33.000.167/0001-01abc"}, + {"leading_letters", "abc33.000.167/0001-01", false, "abc33.000.167/0001-01"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, sanitized, message := ValidateCNPJ(tt.cnpj) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + if sanitized != tt.sanitized { + t.Fatalf("expected sanitized %q, got %q", tt.sanitized, sanitized) + } + }) + } +} diff --git a/utils/validators.go b/utils/validators.go index 48327b8..84f3cf4 100644 --- a/utils/validators.go +++ b/utils/validators.go @@ -39,6 +39,7 @@ var ( // that pass, so "529.982.247-25" is still normalised to "52998224725". var ( cpfChars = regexp.MustCompile(`^[\d.\-\s]*$`) + cnpjChars = regexp.MustCompile(`^[\d.\-/\s]*$`) rgChars = regexp.MustCompile(`^[\d.\-\s]*[xX]?$`) cepChars = regexp.MustCompile(`^[\d\-\s]*$`) phoneChars = regexp.MustCompile(`^[\d+().\-\s]*$`) @@ -159,6 +160,61 @@ func isValidCPF(cpf string) bool { return true } +// ValidateCNPJ 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) { + if !cnpjChars.MatchString(cnpj) { + return false, cnpj, "Invalid CNPJ format (unexpected characters)" + } + + sanitizedCNPJ := nonDigitsRegex.ReplaceAllString(cnpj, "") + if len(sanitizedCNPJ) != 14 { + return false, sanitizedCNPJ, "Invalid CNPJ format (incorrect length)" + } + + // A CNPJ of one repeated digit passes the check-digit arithmetic, so it has + // to be excluded explicitly — 00.000.000/0000-00 is the classic example. + if allSameDigit(sanitizedCNPJ) { + return false, sanitizedCNPJ, "Invalid CNPJ format" + } + + firstWeights := []int{5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2} + secondWeights := []int{6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2} + + if checkDigit(sanitizedCNPJ[:12], firstWeights) != int(sanitizedCNPJ[12]-'0') || + checkDigit(sanitizedCNPJ[:13], secondWeights) != int(sanitizedCNPJ[13]-'0') { + return false, sanitizedCNPJ, "Invalid CNPJ format" + } + + return true, sanitizedCNPJ, "Valid CNPJ format" +} + +// checkDigit applies the weighted sum the Receita Federal specifies: a +// remainder below 2 yields a zero, otherwise the digit is 11 minus it. +func checkDigit(digits string, weights []int) int { + sum := 0 + for i, weight := range weights { + sum += int(digits[i]-'0') * weight + } + + if remainder := sum % 11; remainder >= 2 { + return 11 - remainder + } + return 0 +} + +func allSameDigit(digits string) bool { + for i := 1; i < len(digits); i++ { + if digits[i] != digits[0] { + return false + } + } + return len(digits) > 0 +} + // ValidateEmail validates if the email format is correct. func ValidateEmail(email string) (bool, string, string) { sanitizedEmail := strings.TrimSpace(strings.ToLower(email))