From 761a519928aa181dacf18fc288041cc2f0eecf9c Mon Sep 17 00:00:00 2001 From: Jeferson Siqueira Date: Fri, 4 Sep 2026 09:49:21 +0100 Subject: [PATCH] feat: validate a whole form in one request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /validate/batch takes a list of values, each with its own document type, and answers one result per item plus a summary. A registration form with eight fields was eight round trips; now it is one. The precondition was removing the duplication that would have made this two implementations of the same thing. Every validator now lives in one table in utils/dispatch.go, and both endpoints read from it — so a document added there appears on both at once and neither can quietly fall behind the other. The single-value handler lost its fourteen-branch if/else chain in the process, and its parameter counting is derived from the same table rather than a list kept by hand next to it. Design decisions worth arguing with: Items are a list, not an object keyed by document type. A form can carry two CPFs — a customer and a guarantor — and an object would silently keep only the last. Each item may carry an id, echoed back untouched, so results line up with the caller's own fields without depending on ordering. A batch that ran answers 200 even when items failed validation. The single endpoint answers 422 for an invalid value because there the value is the whole request; here it is one line of many, and failing the response would discard the results that did pass. An unknown document type is reported as UNKNOWN_KEY on that item rather than as an invalid value, and does not fail the batch. Calling it "invalid" would suggest the value had been checked and rejected, and discarding nine good results over one typo helps nobody. Bounded on both axes: at most 100 items, and the body is capped before parsing so a large request cannot exhaust memory ahead of the item check. Unknown JSON fields are refused rather than ignored — a caller who misspells "items" deserves to be told, not handed an empty summary. The parser error is never echoed. It can quote the body, and the body is exactly the personal data that must not travel back out in an error; a test asserts a submitted CPF cannot be found in the failure message. --- docs/docs.go | 146 +++++++++++++++++++++++++++++++++++++++++ docs/swagger.json | 146 +++++++++++++++++++++++++++++++++++++++++ docs/swagger.yaml | 102 ++++++++++++++++++++++++++++ handlers/batch.go | 139 +++++++++++++++++++++++++++++++++++++++ handlers/batch_test.go | 138 ++++++++++++++++++++++++++++++++++++++ handlers/validate.go | 100 +++++++++------------------- main.go | 3 + middleware/cors.go | 2 +- models/response.go | 52 +++++++++++++++ utils/dispatch.go | 79 ++++++++++++++++++++++ 10 files changed, 838 insertions(+), 69 deletions(-) create mode 100644 handlers/batch.go create mode 100644 handlers/batch_test.go create mode 100644 utils/dispatch.go diff --git a/docs/docs.go b/docs/docs.go index 5f2d0e7..8851691 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -141,9 +141,155 @@ const docTemplate = `{ } } } + }, + "/validate/batch": { + "post": { + "description": "Checks a list of values, each with its own document type. Returns one result per item plus a summary.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Validation" + ], + "summary": "Validates several values in one request", + "parameters": [ + { + "description": "Values to validate", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.BatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.BatchResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BatchResponse" + } + } + } + } } }, "definitions": { + "models.BatchItem": { + "type": "object", + "properties": { + "id": { + "description": "ID is echoed back untouched so the caller can line results up with its\nown form fields without relying on ordering.", + "type": "string" + }, + "key": { + "description": "Key is the document type, matching the query parameter names of GET /validate.", + "type": "string" + }, + "qualifier": { + "description": "Qualifier carries the extra context a few documents need — today only the\nissuing state for an inscrição estadual.", + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "models.BatchRequest": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.BatchItem" + } + } + } + }, + "models.BatchResponse": { + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "execution_time_ms": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/models.BatchResult" + } + }, + "status_code": { + "type": "integer" + }, + "summary": { + "$ref": "#/definitions/models.BatchSummary" + }, + "timestamp": { + "type": "string" + } + } + }, + "models.BatchResult": { + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "from_cache": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "is_valid": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "raw_value": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "models.BatchSummary": { + "type": "object", + "properties": { + "invalid": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "valid": { + "type": "integer" + } + } + }, "models.ValidationResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 59335bc..8399a0e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -130,9 +130,155 @@ } } } + }, + "/validate/batch": { + "post": { + "description": "Checks a list of values, each with its own document type. Returns one result per item plus a summary.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Validation" + ], + "summary": "Validates several values in one request", + "parameters": [ + { + "description": "Values to validate", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.BatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.BatchResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BatchResponse" + } + } + } + } } }, "definitions": { + "models.BatchItem": { + "type": "object", + "properties": { + "id": { + "description": "ID is echoed back untouched so the caller can line results up with its\nown form fields without relying on ordering.", + "type": "string" + }, + "key": { + "description": "Key is the document type, matching the query parameter names of GET /validate.", + "type": "string" + }, + "qualifier": { + "description": "Qualifier carries the extra context a few documents need — today only the\nissuing state for an inscrição estadual.", + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "models.BatchRequest": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.BatchItem" + } + } + } + }, + "models.BatchResponse": { + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "execution_time_ms": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/models.BatchResult" + } + }, + "status_code": { + "type": "integer" + }, + "summary": { + "$ref": "#/definitions/models.BatchSummary" + }, + "timestamp": { + "type": "string" + } + } + }, + "models.BatchResult": { + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "from_cache": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "is_valid": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "raw_value": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "models.BatchSummary": { + "type": "object", + "properties": { + "invalid": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "valid": { + "type": "integer" + } + } + }, "models.ValidationResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6713f57..bd16e48 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,79 @@ definitions: + models.BatchItem: + properties: + id: + description: |- + ID is echoed back untouched so the caller can line results up with its + own form fields without relying on ordering. + type: string + key: + description: Key is the document type, matching the query parameter names + of GET /validate. + type: string + qualifier: + description: |- + Qualifier carries the extra context a few documents need — today only the + issuing state for an inscrição estadual. + type: string + value: + type: string + type: object + models.BatchRequest: + properties: + items: + items: + $ref: '#/definitions/models.BatchItem' + type: array + type: object + models.BatchResponse: + properties: + error_code: + type: string + execution_time_ms: + type: integer + message: + type: string + request_id: + type: string + results: + items: + $ref: '#/definitions/models.BatchResult' + type: array + status_code: + type: integer + summary: + $ref: '#/definitions/models.BatchSummary' + timestamp: + type: string + type: object + models.BatchResult: + properties: + error_code: + type: string + from_cache: + type: boolean + id: + type: string + is_valid: + type: boolean + key: + type: string + message: + type: string + raw_value: + type: string + value: + type: string + type: object + models.BatchSummary: + properties: + invalid: + type: integer + total: + type: integer + valid: + type: integer + type: object models.ValidationResponse: properties: error_code: @@ -115,4 +190,31 @@ paths: summary: Validates provided data format tags: - Validation + /validate/batch: + post: + consumes: + - application/json + description: Checks a list of values, each with its own document type. Returns + one result per item plus a summary. + parameters: + - description: Values to validate + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.BatchRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.BatchResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.BatchResponse' + summary: Validates several values in one request + tags: + - Validation swagger: "2.0" diff --git a/handlers/batch.go b/handlers/batch.go new file mode 100644 index 0000000..e50412d --- /dev/null +++ b/handlers/batch.go @@ -0,0 +1,139 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "DataValidatorAPI/models" + "DataValidatorAPI/utils" + + "github.com/google/uuid" +) + +const ( + // maxBatchItems bounds the work one request can ask for. A form has tens of + // fields, not thousands; anything past this is either a mistake or an + // attempt to use the service as free compute. + maxBatchItems = 100 + // maxBatchBytes bounds what will be read before parsing, so a large body + // cannot exhaust memory ahead of the item check. + maxBatchBytes = 1 << 20 +) + +// BatchHandler godoc +// @Summary Validates several values in one request +// @Description Checks a list of values, each with its own document type. Returns one result per item plus a summary. +// @Tags Validation +// @Accept json +// @Produce json +// @Param request body models.BatchRequest true "Values to validate" +// @Success 200 {object} models.BatchResponse +// @Failure 400 {object} models.BatchResponse +// @Router /validate/batch [post] +func BatchHandler(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + var request models.BatchRequest + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBatchBytes)) + // Unknown fields are refused rather than ignored: a caller who misspells + // "items" deserves to be told, not to receive an empty summary. + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&request); err != nil { + // The parser error is not echoed — it can quote the body, and the body + // is exactly the personal data that must not travel back in an error. + writeBatchError(w, start, "MALFORMED_BODY", "The request body could not be read as a batch of items") + return + } + + switch { + case len(request.Items) == 0: + writeBatchError(w, start, "EMPTY_BATCH", "No items to validate") + return + case len(request.Items) > maxBatchItems: + writeBatchError(w, start, "BATCH_TOO_LARGE", + "A batch carries at most "+strconv.Itoa(maxBatchItems)+" items") + return + } + + results := make([]models.BatchResult, 0, len(request.Items)) + summary := models.BatchSummary{Total: len(request.Items)} + + for _, item := range request.Items { + result := validateItem(item) + if result.IsValid { + summary.Valid++ + } else { + summary.Invalid++ + } + results = append(results, result) + } + + // A batch that ran is a successful request even when items failed + // validation. The single-value endpoint answers 422 for an invalid value + // because the value is the whole request; here it is one line of many, and + // failing the response would discard the results that did pass. + writeBatch(w, models.BatchResponse{ + StatusCode: http.StatusOK, + Summary: summary, + Results: results, + RequestID: uuid.New().String(), + Timestamp: time.Now(), + ExecutionTimeMs: int(time.Since(start).Milliseconds()), + }) +} + +func validateItem(item models.BatchItem) models.BatchResult { + key := strings.TrimSpace(strings.ToLower(item.Key)) + // "phone" is an accepted alias on the single endpoint and stays one here. + if key == "phone" { + key = "telephone" + } + + result := models.BatchResult{ID: item.ID, Key: key, RawValue: item.Value, Value: item.Value} + + if strings.TrimSpace(item.Value) == "" { + result.ErrorCode = "MISSING_VALUE" + result.Message = "No value provided for this item" + return result + } + + outcome, known := utils.Validate(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(), ", ") + ")" + return result + } + + result.Value = outcome.Sanitized + result.IsValid = outcome.Valid + result.Message = outcome.Message + result.FromCache = outcome.FromCache + return result +} + +func writeBatchError(w http.ResponseWriter, start time.Time, code, message string) { + writeBatch(w, models.BatchResponse{ + StatusCode: http.StatusBadRequest, + ErrorCode: code, + Message: message, + Summary: models.BatchSummary{}, + Results: []models.BatchResult{}, + RequestID: uuid.New().String(), + Timestamp: time.Now(), + ExecutionTimeMs: int(time.Since(start).Milliseconds()), + }) +} + +func writeBatch(w http.ResponseWriter, response models.BatchResponse) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _ = json.NewEncoder(w).Encode(response) +} diff --git a/handlers/batch_test.go b/handlers/batch_test.go new file mode 100644 index 0000000..757a6b7 --- /dev/null +++ b/handlers/batch_test.go @@ -0,0 +1,138 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "DataValidatorAPI/models" +) + +func postBatch(t *testing.T, body string) (*httptest.ResponseRecorder, models.BatchResponse) { + t.Helper() + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/validate/batch", strings.NewReader(body)) + BatchHandler(recorder, request) + + var response models.BatchResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("could not decode the response: %v — body was %s", err, recorder.Body.String()) + } + return recorder, response +} + +func TestBatchValidatesEveryItem(t *testing.T) { + recorder, response := postBatch(t, `{"items":[ + {"id":"a","key":"cpf","value":"529.982.247-25"}, + {"id":"b","key":"cnpj","value":"33.000.167/0001-01"}, + {"id":"c","key":"email","value":"jeferson@example.com"}, + {"id":"d","key":"ie","value":"0100482300112","qualifier":"AC"}, + {"id":"e","key":"cpf","value":"529.982.247-26"} + ]}`) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", recorder.Code) + } + if response.Summary.Total != 5 || response.Summary.Valid != 4 || response.Summary.Invalid != 1 { + t.Fatalf("unexpected summary: %+v", response.Summary) + } + // The ids come back untouched, so a caller can line results up with its own + // fields without depending on ordering. + for i, want := range []string{"a", "b", "c", "d", "e"} { + if response.Results[i].ID != want { + t.Fatalf("result %d carried id %q, expected %q", i, response.Results[i].ID, want) + } + } + if !response.Results[0].IsValid || response.Results[4].IsValid { + t.Fatalf("the CPFs were judged wrongly: %+v", response.Results) + } +} + +// A form can legitimately carry two of the same document — a customer and a +// guarantor — which is why items are a list rather than an object. +func TestBatchAcceptsRepeatedKeys(t *testing.T) { + _, response := postBatch(t, `{"items":[ + {"id":"titular","key":"cpf","value":"529.982.247-25"}, + {"id":"avalista","key":"cpf","value":"168.995.350-09"} + ]}`) + + if response.Summary.Total != 2 || response.Summary.Valid != 2 { + t.Fatalf("expected both CPFs to be checked and valid: %+v", response.Summary) + } +} + +// One bad item must not discard the results that did pass. +func TestBatchReportsUnknownKeyWithoutFailingTheRequest(t *testing.T) { + recorder, response := postBatch(t, `{"items":[ + {"key":"cpf","value":"529.982.247-25"}, + {"key":"passaporte","value":"AB123456"} + ]}`) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected the batch itself to succeed, got %d", recorder.Code) + } + if response.Results[1].ErrorCode != "UNKNOWN_KEY" { + t.Fatalf("expected UNKNOWN_KEY, got %+v", response.Results[1]) + } + if !response.Results[0].IsValid { + t.Fatal("a valid item was lost because another item had a bad key") + } +} + +func TestBatchRejectsBadRequests(t *testing.T) { + tests := []struct { + name string + body string + code string + }{ + {"malformed_json", `{"items":[`, "MALFORMED_BODY"}, + {"unknown_field", `{"itens":[{"key":"cpf","value":"1"}]}`, "MALFORMED_BODY"}, + {"empty", `{"items":[]}`, "EMPTY_BATCH"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder, response := postBatch(t, tt.body) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", recorder.Code) + } + if response.ErrorCode != tt.code { + t.Fatalf("expected %s, got %s", tt.code, response.ErrorCode) + } + }) + } +} + +func TestBatchRefusesOversizedBatches(t *testing.T) { + items := make([]string, 0, maxBatchItems+1) + for i := 0; i <= maxBatchItems; i++ { + items = append(items, `{"key":"cpf","value":"529.982.247-25"}`) + } + + recorder, response := postBatch(t, fmt.Sprintf(`{"items":[%s]}`, strings.Join(items, ","))) + if recorder.Code != http.StatusBadRequest || response.ErrorCode != "BATCH_TOO_LARGE" { + t.Fatalf("expected BATCH_TOO_LARGE, got %d %s", recorder.Code, response.ErrorCode) + } +} + +// A parser error can quote the body, and the body is the personal data that +// must never travel back out in an error message. +func TestBatchErrorDoesNotEchoTheBody(t *testing.T) { + _, response := postBatch(t, `{"items":[{"key":"cpf","value":"529.982.247-25"}`) + + if strings.Contains(response.Message, "529") || strings.Contains(response.Message, "982") { + t.Fatalf("the error message carries the submitted value: %s", response.Message) + } +} + +func TestBatchMissingValue(t *testing.T) { + _, response := postBatch(t, `{"items":[{"key":"cpf","value":" "}]}`) + + if response.Results[0].ErrorCode != "MISSING_VALUE" { + t.Fatalf("expected MISSING_VALUE, got %+v", response.Results[0]) + } +} diff --git a/handlers/validate.go b/handlers/validate.go index 50c2b0e..a52f076 100644 --- a/handlers/validate.go +++ b/handlers/validate.go @@ -53,50 +53,9 @@ func ValidateHandler(w http.ResponseWriter, r *http.Request) { return } - if email := r.URL.Query().Get("email"); email != "" { - isValid, sanitizedValue, message := utils.ValidateEmail(email) - response = createResponse("email", email, sanitizedValue, isValid, message, start, false) - } 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 documento := r.URL.Query().Get("documento"); documento != "" { - isValid, sanitizedValue, message := utils.ValidateDocument(documento) - response = createResponse("documento", documento, sanitizedValue, isValid, message, start, false) - } else if ie := r.URL.Query().Get("ie"); ie != "" { - isValid, sanitizedValue, message := utils.ValidateInscricaoEstadual(ie, r.URL.Query().Get("uf")) - response = createResponse("ie", ie, sanitizedValue, isValid, message, start, false) - } else if pis := r.URL.Query().Get("pis"); pis != "" { - isValid, sanitizedValue, message := utils.ValidatePIS(pis) - response = createResponse("pis", pis, sanitizedValue, isValid, message, start, false) - } else if titulo := r.URL.Query().Get("titulo"); titulo != "" { - isValid, sanitizedValue, message := utils.ValidateTituloEleitor(titulo) - response = createResponse("titulo", titulo, sanitizedValue, isValid, message, start, false) - } else if placa := r.URL.Query().Get("placa"); placa != "" { - isValid, sanitizedValue, message := utils.ValidatePlate(placa) - response = createResponse("placa", placa, sanitizedValue, isValid, message, start, false) - } else if pix := r.URL.Query().Get("pix"); pix != "" { - isValid, sanitizedValue, message := utils.ValidatePixKey(pix) - response = createResponse("pix", pix, 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) - } else if telephone := firstNonEmpty(r.URL.Query().Get("telephone"), r.URL.Query().Get("phone")); telephone != "" { - isValid, sanitizedValue, message := utils.ValidatePhone(telephone) - response = createResponse("telephone", telephone, sanitizedValue, isValid, message, start, false) - } else if plastic := r.URL.Query().Get("plastic"); plastic != "" { - isValid, sanitizedValue, message := utils.ValidatePlastic(plastic) - response = createResponse("plastic", plastic, sanitizedValue, isValid, message, start, false) - } else if rg := r.URL.Query().Get("rg"); rg != "" { - isValid, sanitizedValue, message := utils.ValidateRG(rg) - response = createResponse("rg", rg, sanitizedValue, isValid, message, start, false) - } else if cep := r.URL.Query().Get("cep"); cep != "" { - isValid, sanitizedValue, message := utils.ValidateCEP(cep) - response = createResponse("cep", cep, sanitizedValue, isValid, message, start, false) - } else { - response = models.ValidationResponse{ + key, value := firstProvided(r) + if key == "" { + writeResponse(w, models.ValidationResponse{ StatusCode: http.StatusBadRequest, ErrorCode: "MISSING_PARAMETER", Message: "No validation parameter provided", @@ -104,41 +63,46 @@ func ValidateHandler(w http.ResponseWriter, r *http.Request) { RequestID: uuid.New().String(), Timestamp: time.Now(), ExecutionTimeMs: int(time.Since(start).Milliseconds()), - } + }) + return } + // 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) + writeResponse(w, response) } +// firstProvided returns the first validation parameter present, in the order +// 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 { + value := r.URL.Query().Get(key) + if key == "telephone" { + value = firstNonEmpty(value, r.URL.Query().Get("phone")) + } + if strings.TrimSpace(value) != "" { + return key, value + } + } + return "", "" +} + func validationCount(r *http.Request) int { count := 0 - if strings.TrimSpace(r.URL.Query().Get("email")) != "" { - count++ - } - for _, key := range []string{"cnpj", "documento", "pis", "titulo", "placa", "pix", "ie"} { - if strings.TrimSpace(r.URL.Query().Get(key)) != "" { + for _, key := range utils.ValidationKeys { + value := r.URL.Query().Get(key) + if key == "telephone" { + value = firstNonEmpty(value, r.URL.Query().Get("phone")) + } + if strings.TrimSpace(value) != "" { count++ } } - if strings.TrimSpace(r.URL.Query().Get("cpf")) != "" { - count++ - } - if strings.TrimSpace(r.URL.Query().Get("name")) != "" { - count++ - } - if firstNonEmpty(r.URL.Query().Get("telephone"), r.URL.Query().Get("phone")) != "" { - count++ - } - if strings.TrimSpace(r.URL.Query().Get("plastic")) != "" { - count++ - } - if strings.TrimSpace(r.URL.Query().Get("rg")) != "" { - count++ - } - if strings.TrimSpace(r.URL.Query().Get("cep")) != "" { - count++ - } return count } diff --git a/main.go b/main.go index 65a3537..444c9b3 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,9 @@ func main() { router.GET("/validate", func(c *gin.Context) { handlers.ValidateHandler(c.Writer, c.Request) }) + router.POST("/validate/batch", func(c *gin.Context) { + handlers.BatchHandler(c.Writer, c.Request) + }) server := &http.Server{ Addr: ":" + port(), diff --git a/middleware/cors.go b/middleware/cors.go index 3e975fa..79ec7af 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -47,7 +47,7 @@ func CORS() gin.HandlerFunc { for _, allowed := range allowedOrigins() { if origin != "" && origin == allowed { c.Header("Access-Control-Allow-Origin", origin) - c.Header("Access-Control-Allow-Methods", "GET, OPTIONS") + c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type") c.Header("Access-Control-Max-Age", "86400") break diff --git a/models/response.go b/models/response.go index ab1be02..c074306 100644 --- a/models/response.go +++ b/models/response.go @@ -16,3 +16,55 @@ type ValidationResponse struct { LocationData map[string]interface{} `json:"location_data,omitempty"` FromCache bool `json:"from_cache"` } + +// BatchRequest is one form's worth of values, checked in a single call. +// +// The items are a list rather than an object keyed by document type: a form can +// legitimately carry two CPFs — a customer and a guarantor — and an object +// would silently keep only the last. +type BatchRequest struct { + Items []BatchItem `json:"items"` +} + +// BatchItem is one value to check. +type BatchItem struct { + // ID is echoed back untouched so the caller can line results up with its + // own form fields without relying on ordering. + ID string `json:"id,omitempty"` + // Key is the document type, matching the query parameter names of GET /validate. + Key string `json:"key"` + Value string `json:"value"` + // Qualifier carries the extra context a few documents need — today only the + // issuing state for an inscrição estadual. + Qualifier string `json:"qualifier,omitempty"` +} + +// BatchResponse reports every item plus a count, so a caller can branch on the +// summary without walking the list. +type BatchResponse struct { + StatusCode int `json:"status_code"` + ErrorCode string `json:"error_code,omitempty"` + Message string `json:"message,omitempty"` + Summary BatchSummary `json:"summary"` + Results []BatchResult `json:"results"` + RequestID string `json:"request_id"` + Timestamp time.Time `json:"timestamp"` + ExecutionTimeMs int `json:"execution_time_ms"` +} + +type BatchSummary struct { + Total int `json:"total"` + Valid int `json:"valid"` + Invalid int `json:"invalid"` +} + +type BatchResult struct { + ID string `json:"id,omitempty"` + Key string `json:"key"` + ErrorCode string `json:"error_code,omitempty"` + RawValue string `json:"raw_value"` + Value string `json:"value"` + IsValid bool `json:"is_valid"` + Message string `json:"message"` + FromCache bool `json:"from_cache"` +} diff --git a/utils/dispatch.go b/utils/dispatch.go new file mode 100644 index 0000000..1bf5a8b --- /dev/null +++ b/utils/dispatch.go @@ -0,0 +1,79 @@ +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), + "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", + "placa", "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 +}