From d31ce8e871355661c89b869db5b57c7cef58d838 Mon Sep 17 00:00:00 2001 From: Adam Shannon Date: Fri, 26 Apr 2024 09:49:05 -0500 Subject: [PATCH] feat: standardize validate opts query params, accept on GET/POST validate --- docs/create-file.md | 8 +- openapi.yaml | 99 +++++++++++++ server/files.go | 117 ++------------- .../unordered_batches/unordered_batch_test.go | 13 ++ server/validate.go | 137 ++++++++++++++++++ server/validate_test.go | 44 ++++++ 6 files changed, 313 insertions(+), 105 deletions(-) create mode 100644 server/validate.go create mode 100644 server/validate_test.go diff --git a/docs/create-file.md b/docs/create-file.md index dbcf1a7c4..3cc2fd895 100644 --- a/docs/create-file.md +++ b/docs/create-file.md @@ -48,10 +48,11 @@ Example: `POST /files/create?requireABAOrigin=true&bypassDestination=true` | `allowInvalidCheckDigit` | `AllowInvalidCheckDigit` | | `allowMissingFileControl` | `AllowMissingFileControl` | | `allowMissingFileHeader` | `AllowMissingFileHeader` | +| `allowUnorderedBatchNumbers` | `AllowUnorderedBatchNumbers` | | `allowZeroBatches` | `AllowZeroBatches` | | `bypassCompanyIdentificationMatch` | `BypassCompanyIdentificationMatch` | -| `bypassDestination` | `BypassDestinationValidation` | -| `bypassOrigin` | `BypassOriginValidation` | +| `bypassDestinationValidation` | `BypassDestinationValidation` | +| `bypassOriginValidation` | `BypassOriginValidation` | | `customReturnCodes` | `CustomReturnCodes` | | `customTraceNumbers` | `CustomTraceNumbers` | | `preserveSpaces` | `PreserveSpaces` | @@ -59,7 +60,8 @@ Example: `POST /files/create?requireABAOrigin=true&bypassDestination=true` | `skipAll` | `SkipAll` | | `unequalAddendaCounts` | `UnequalAddendaCounts` | | `unequalServiceClassCode` | `UnequalServiceClassCode` | -| `unorderedBatchNumbers` | `AllowUnorderedBatchNumbers` | + +> Note: `bypassDestination`, `bypassOrigin`, and `unorderedBatchNumbers` are deprecated query parameters replace by identical named parameters. ## Upload a raw ACH file diff --git a/openapi.yaml b/openapi.yaml index fbd8f3cd8..4cfb2d688 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -89,11 +89,23 @@ paths: description: Optional parameter to configure ImmediateOrigin validation schema: type: boolean + deprecated: true + - name: bypassOriginValidation + in: query + description: Optional parameter to configure ImmediateOrigin validation + schema: + type: boolean - name: bypassDestination in: query description: Optional parameter to configure ImmediateDestination validation schema: type: boolean + deprecated: true + - name: bypassDestinationValidation + in: query + description: Optional parameter to configure ImmediateDestination validation + schema: + type: boolean - name: customTraceNumbers in: query description: Optional parameter to configure ImmediateDestination validation @@ -134,6 +146,12 @@ paths: description: Allow a file to be read with unordered batch numbers. schema: type: boolean + deprecated: true + - name: allowUnorderedBatchNumbers + in: query + description: Allow a file to be read with unordered batch numbers. + schema: + type: boolean - name: allowInvalidCheckDigit in: query description: Allow the CheckDigit field in EntryDetail to differ from the expected calculation @@ -304,6 +322,87 @@ paths: schema: $ref: '#/components/schemas/RawFile' /files/{fileID}/validate: + parameters: + - name: skipAll + in: query + description: Optional parameter to disable all validation checks for a File + schema: + type: boolean + - name: requireABAOrigin + in: query + description: Optional parameter to configure ImmediateOrigin validation + schema: + type: boolean + - name: bypassOriginValidation + in: query + description: Optional parameter to configure ImmediateOrigin validation + schema: + type: boolean + - name: bypassDestinationValidation + in: query + description: Optional parameter to configure ImmediateDestination validation + schema: + type: boolean + - name: customTraceNumbers + in: query + description: Optional parameter to configure ImmediateDestination validation + schema: + type: boolean + - name: allowZeroBatches + in: query + description: Optional parameter to configure ImmediateDestination validation + schema: + type: boolean + - name: allowMissingFileHeader + in: query + description: Optional parameter to configure ImmediateDestination validation + schema: + type: boolean + - name: allowMissingFileControl + in: query + description: Optional parameter to configure ImmediateDestination validation + schema: + type: boolean + - name: bypassCompanyIdentificationMatch + in: query + description: Optional parameter to configure ImmediateDestination validation + schema: + type: boolean + - name: customReturnCodes + in: query + description: Optional parameter to configure ImmediateDestination validation + schema: + type: boolean + - name: unequalServiceClassCode + in: query + description: Optional parameter to configure ImmediateDestination validation + schema: + type: boolean + - name: allowUnorderedBatchNumbers + in: query + description: Allow a file to be read with unordered batch numbers. + schema: + type: boolean + - name: allowInvalidCheckDigit + in: query + description: Allow the CheckDigit field in EntryDetail to differ from the expected calculation + schema: + type: boolean + - name: unequalAddendaCounts + in: query + description: Optional parameter to configure UnequalAddendaCounts validation + schema: + type: boolean + - name: preserveSpaces + in: query + description: Optional parameter to save all padding spaces + schema: + type: boolean + - name: allowInvalidAmounts + in: query + description: Optional parameter to save all padding spaces + schema: + type: boolean get: tags: ['ACH Files'] summary: Validate File diff --git a/server/files.go b/server/files.go index 27b6bb8cb..ffa2a94c2 100644 --- a/server/files.go +++ b/server/files.go @@ -25,7 +25,6 @@ import ( "fmt" "io" "net/http" - "strconv" "strings" "github.com/moov-io/ach" @@ -117,104 +116,17 @@ func createFileEndpoint(s Service, r Repository, logger log.Logger) endpoint.End func decodeCreateFileRequest(_ context.Context, request *http.Request) (interface{}, error) { var r io.Reader req := createFileRequest{ - File: ach.NewFile(), - requestID: moovhttp.GetRequestID(request), - validateOpts: &ach.ValidateOpts{}, - } - - const ( - skipAll = "skipAll" - requireABAOrigin = "requireABAOrigin" - bypassOrigin = "bypassOrigin" - bypassDestination = "bypassDestination" - customTraceNumbers = "customTraceNumbers" - allowZeroBatches = "allowZeroBatches" - allowMissingFileHeader = "allowMissingFileHeader" - allowMissingFileControl = "allowMissingFileControl" - bypassCompanyIdentificationMatch = "bypassCompanyIdentificationMatch" - customReturnCodes = "customReturnCodes" - unequalServiceClassCode = "unequalServiceClassCode" - unorderedBatchNumbers = "unorderedBatchNumbers" - allowInvalidCheckDigit = "allowInvalidCheckDigit" - unequalAddendaCounts = "unequalAddendaCounts" - preserveSpaces = "preserveSpaces" - allowInvalidAmounts = "allowInvalidAmounts" - ) - - validationNames := []string{ - skipAll, - requireABAOrigin, - bypassOrigin, - bypassDestination, - customTraceNumbers, - allowZeroBatches, - allowMissingFileHeader, - allowMissingFileControl, - bypassCompanyIdentificationMatch, - customReturnCodes, - unequalServiceClassCode, - unorderedBatchNumbers, - allowInvalidCheckDigit, - unequalAddendaCounts, - preserveSpaces, - allowInvalidAmounts, - } - - for _, name := range validationNames { - q := request.URL.Query() - if q == nil { - continue - } - input := q.Get(name) - if input == "" { - continue - } - - ok, err := strconv.ParseBool(input) - if err != nil { - return nil, fmt.Errorf("invalid bool: %v", err) - } - if !ok { - continue - } - - switch name { - case skipAll: - req.validateOpts.SkipAll = true - case requireABAOrigin: - req.validateOpts.RequireABAOrigin = true - case bypassOrigin: - req.validateOpts.BypassOriginValidation = true - case bypassDestination: - req.validateOpts.BypassDestinationValidation = true - case customTraceNumbers: - req.validateOpts.CustomTraceNumbers = true - case allowZeroBatches: - req.validateOpts.AllowZeroBatches = true - case allowMissingFileHeader: - req.validateOpts.AllowMissingFileHeader = true - case allowMissingFileControl: - req.validateOpts.AllowMissingFileControl = true - case bypassCompanyIdentificationMatch: - req.validateOpts.BypassCompanyIdentificationMatch = true - case customReturnCodes: - req.validateOpts.CustomReturnCodes = true - case unequalServiceClassCode: - req.validateOpts.UnequalServiceClassCode = true - case unorderedBatchNumbers: - req.validateOpts.AllowUnorderedBatchNumbers = true - case allowInvalidCheckDigit: - req.validateOpts.AllowInvalidCheckDigit = true - case unequalAddendaCounts: - req.validateOpts.UnequalAddendaCounts = true - case preserveSpaces: - req.validateOpts.PreserveSpaces = true - case allowInvalidAmounts: - req.validateOpts.AllowInvalidAmounts = true - } - } - - bs, err := io.ReadAll(request.Body) + File: ach.NewFile(), + requestID: moovhttp.GetRequestID(request), + } + + body, validateOpts, err := readValidateOpts(request) + if err != nil { + return nil, err + } + req.validateOpts = validateOpts + + bs, err := io.ReadAll(body) if err != nil { return nil, err } @@ -538,10 +450,11 @@ func decodeValidateFileRequest(_ context.Context, r *http.Request) (interface{}, requestID: moovhttp.GetRequestID(r), } - var opts ach.ValidateOpts - if err := json.NewDecoder(r.Body).Decode(&opts); err == nil { - req.opts = &opts + _, validateOpts, err := readValidateOpts(r) + if err != nil { + return nil, err } + req.opts = validateOpts return req, nil } diff --git a/server/test/unordered_batches/unordered_batch_test.go b/server/test/unordered_batches/unordered_batch_test.go index b2dc961ef..260e502df 100644 --- a/server/test/unordered_batches/unordered_batch_test.go +++ b/server/test/unordered_batches/unordered_batch_test.go @@ -51,6 +51,19 @@ func TestUnorderedBatches(t *testing.T) { req = httptest.NewRequest("POST", fmt.Sprintf("/files/%s/validate", response.ID), &buf) server.Handler.ServeHTTP(w, req) w.Flush() + require.Equal(t, http.StatusOK, w.Code) + // Try POST /validate with ?unorderedBatchNumbers + w = httptest.NewRecorder() + req = httptest.NewRequest("POST", fmt.Sprintf("/files/%s/validate?unorderedBatchNumbers=true", response.ID), nil) + server.Handler.ServeHTTP(w, req) + w.Flush() + require.Equal(t, http.StatusOK, w.Code) + + // Try POST /validate with ?allowUnorderedBatchNumbers + w = httptest.NewRecorder() + req = httptest.NewRequest("POST", fmt.Sprintf("/files/%s/validate?allowUnorderedBatchNumbers=true", response.ID), nil) + server.Handler.ServeHTTP(w, req) + w.Flush() require.Equal(t, http.StatusOK, w.Code) } diff --git a/server/validate.go b/server/validate.go new file mode 100644 index 000000000..069f9b49a --- /dev/null +++ b/server/validate.go @@ -0,0 +1,137 @@ +// Licensed to The Moov Authors under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. The Moov Authors licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/moov-io/ach" +) + +const ( + skipAll = "skipAll" + requireABAOrigin = "requireABAOrigin" + bypassOrigin = "bypassOrigin" + bypassOriginValidation = "bypassOriginValidation" + bypassDestination = "bypassDestination" + bypassDestinationValidation = "bypassDestinationValidation" + customTraceNumbers = "customTraceNumbers" + allowZeroBatches = "allowZeroBatches" + allowMissingFileHeader = "allowMissingFileHeader" + allowMissingFileControl = "allowMissingFileControl" + bypassCompanyIdentificationMatch = "bypassCompanyIdentificationMatch" + customReturnCodes = "customReturnCodes" + unequalServiceClassCode = "unequalServiceClassCode" + unorderedBatchNumbers = "unorderedBatchNumbers" + allowUnorderedBatchNumbers = "allowUnorderedBatchNumbers" + allowInvalidCheckDigit = "allowInvalidCheckDigit" + unequalAddendaCounts = "unequalAddendaCounts" + preserveSpaces = "preserveSpaces" + allowInvalidAmounts = "allowInvalidAmounts" +) + +// readValidateOpts parses ValidateOpts from the URL query parameters and from the request body. +// A copy of the request body is returned. Callers are responsible for closing the body. +// +// Query parameters override the JSON body +func readValidateOpts(request *http.Request) (io.Reader, *ach.ValidateOpts, error) { + validationNames := []string{ + skipAll, + requireABAOrigin, + bypassOrigin, + bypassOriginValidation, + bypassDestination, + bypassDestinationValidation, + customTraceNumbers, + allowZeroBatches, + allowMissingFileHeader, + allowMissingFileControl, + bypassCompanyIdentificationMatch, + customReturnCodes, + unequalServiceClassCode, + unorderedBatchNumbers, + allowUnorderedBatchNumbers, + allowInvalidCheckDigit, + unequalAddendaCounts, + preserveSpaces, + allowInvalidAmounts, + } + + var buf bytes.Buffer + bs, _ := io.ReadAll(io.TeeReader(request.Body, &buf)) + + opts := &ach.ValidateOpts{} + json.Unmarshal(bs, opts) + + for _, name := range validationNames { + q := request.URL.Query() + if q == nil { + continue + } + input := q.Get(name) + if input == "" { + continue + } + + yes, err := strconv.ParseBool(input) + if err != nil { + return nil, nil, fmt.Errorf("%s is an invalid boolean: %v", name, err) + } + switch name { + case skipAll: + opts.SkipAll = yes + case requireABAOrigin: + opts.RequireABAOrigin = yes + case bypassOrigin, bypassOriginValidation: + opts.BypassOriginValidation = yes + case bypassDestination, bypassDestinationValidation: + opts.BypassDestinationValidation = yes + case customTraceNumbers: + opts.CustomTraceNumbers = yes + case allowZeroBatches: + opts.AllowZeroBatches = yes + case allowMissingFileHeader: + opts.AllowMissingFileHeader = yes + case allowMissingFileControl: + opts.AllowMissingFileControl = yes + case bypassCompanyIdentificationMatch: + opts.BypassCompanyIdentificationMatch = yes + case customReturnCodes: + opts.CustomReturnCodes = yes + case unequalServiceClassCode: + opts.UnequalServiceClassCode = yes + case unorderedBatchNumbers, allowUnorderedBatchNumbers: + opts.AllowUnorderedBatchNumbers = yes + case allowInvalidCheckDigit: + opts.AllowInvalidCheckDigit = yes + case unequalAddendaCounts: + opts.UnequalAddendaCounts = yes + case preserveSpaces: + opts.PreserveSpaces = yes + case allowInvalidAmounts: + opts.AllowInvalidAmounts = yes + } + } + + return &buf, opts, nil +} diff --git a/server/validate_test.go b/server/validate_test.go new file mode 100644 index 000000000..7a65c0eb3 --- /dev/null +++ b/server/validate_test.go @@ -0,0 +1,44 @@ +// Licensed to The Moov Authors under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. The Moov Authors licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package server + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadValidateOpts(t *testing.T) { + body := strings.NewReader(`{ + "bypassOriginValidation":true, + "bypassDestinationValidation":true, + "allowUnorderedBatchNumbers":true +}`) + req, err := http.NewRequest("POST", "/files/f1/validate?bypassDestination=false&allowInvalidCheckDigit=true", body) + require.NoError(t, err) + + _, opts, err := readValidateOpts(req) + require.NoError(t, err) + + require.True(t, opts.BypassOriginValidation) + require.False(t, opts.BypassDestinationValidation) // query params override body + require.True(t, opts.AllowUnorderedBatchNumbers) + require.True(t, opts.AllowInvalidCheckDigit) +}