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))