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 +}