Repository: moov-io/customers (go)

Issue:
Add endpoint for a single account
There are situations where you would want to request one account by ID, but the API currently only supports fetching a list of accounts for a customer.

Proposed endpoint: `/customers/:customerID/accounts/:accountID`

Why would this be useful?
- When creating an account, the response doesn't include the institution information, so either that should change OR there should be an efficient way to fetch that newly created account and not have to filter a list.
- In a UI reflecting transfer details, we need to look up the account, again it would be ideal to not have to filter a list.


Pull request description:
http: add endpoint to get an Account
Add http endpoint to get an account by `customerID` and `accountID`

Fixes #170 

Patch:
diff --git a/api/client.yaml b/api/client.yaml
index 23074aa..adf543b 100644
--- a/api/client.yaml
+++ b/api/client.yaml
@@ -456,6 +456,57 @@ paths:
             application/json:
               schema:
                 $ref: 'https://raw.githubusercontent.com/moov-io/api/master/openapi-common.yaml#/components/schemas/Error'
+  /customers/{customerID}/accounts/{accountID}:
+    get:
+      description: Retrieve an account by ID for the given customer.
+      operationId: getCustomerAccountByID
+      parameters:
+        - name: X-Request-ID
+          in: header
+          description: Optional requestID allows application developer to trace requests through the systems logs
+          example: rs4f9915
+          schema:
+            type: string
+        - name: X-Namespace
+          in: header
+          description: Value used to separate and identify models
+          example: org342
+          schema:
+            type: string
+        - description: customerID of the Customer to get an Account for
+          explode: false
+          in: path
+          name: customerID
+          required: true
+          schema:
+            example: e210a9d6-d755-4455-9bd2-9577ea7e1081
+            type: string
+          style: simple
+        - description: accountID of the Account
+          explode: false
+          in: path
+          name: accountID
+          required: true
+          schema:
+            example: b74d7c51
+            type: string
+          style: simple
+      responses:
+        "200":
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Account'
+          description: An active account for the customer
+        "400":
+          content:
+            application/json:
+              schema:
+                $ref: 'https://raw.githubusercontent.com/moov-io/api/master/openapi-common.yaml#/components/schemas/Error'
+          description: Failed to get accounts, see error(s)
+      summary: Get Customer Account by ID
+      tags:
+        - Customers
   /customers/{customerID}/accounts/{accountID}/decrypt:
     post:
       tags: [Customers]
diff --git a/cmd/server/accounts/router.go b/cmd/server/accounts/router.go
index 432756a..67773d0 100644
--- a/cmd/server/accounts/router.go
+++ b/cmd/server/accounts/router.go
@@ -13,7 +13,9 @@ import (
 
 	"github.com/moov-io/ach"
 	moovhttp "github.com/moov-io/base/http"
+
 	"github.com/moov-io/customers/cmd/server/accounts/validator"
+
 	"github.com/moov-io/customers/cmd/server/fed"
 	"github.com/moov-io/customers/cmd/server/route"
 	"github.com/moov-io/customers/pkg/client"
@@ -31,6 +33,7 @@ func RegisterRoutes(logger log.Logger, r *mux.Router, accounts Repository, valid
 	r.Methods("POST").Path("/customers/{customerID}/accounts/{accountID}/decrypt").HandlerFunc(decryptAccountNumber(logger, accounts, keeper, transitKeeper))
 	r.Methods("DELETE").Path("/customers/{customerID}/accounts/{accountID}").HandlerFunc(removeCustomerAccount(logger, accounts))
 
+	r.Methods("GET").Path("/customers/{customerID}/accounts/{accountID}").HandlerFunc(getCustomerAccountByID(logger, accounts, fedClient))
 	r.Methods("GET").Path("/customers/{customerID}/accounts/{accountID}/ofac").HandlerFunc(getAccountOfacSearch(logger, accounts))
 	r.Methods("PUT").Path("/customers/{customerID}/accounts/{accountID}/refresh/ofac").HandlerFunc(refreshAccountOfac(logger, accounts, ofac))
 
@@ -163,6 +166,39 @@ func createCustomerAccount(logger log.Logger, repo Repository, fedClient fed.Cli
 	}
 }
 
+func getCustomerAccountByID(logger log.Logger, repo Repository, fedClient fed.Client) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w = route.Responder(logger, w, r)
+		customerID := route.GetCustomerID(w, r)
+		accountID := getAccountID(w, r)
+
+		account, err := repo.getCustomerAccount(customerID, accountID)
+		if err != nil {
+			moovhttp.Problem(w, err)
+			return
+		}
+
+		if account == nil {
+			moovhttp.Problem(w, fmt.Errorf("account with customerID=%s and accountID=%s not found", customerID, accountID))
+			return
+		}
+
+		details, err := fedClient.LookupInstitution(account.RoutingNumber)
+		if err != nil {
+			moovhttp.Problem(w, fmt.Errorf("looking up institution details: %v", err))
+			return
+		}
+
+		if details != nil {
+			account.Institution = *details
+		}
+
+		w.Header().Set("Content-Type", "application/json; charset=utf-8")
+		w.WriteHeader(http.StatusOK)
+		json.NewEncoder(w).Encode(account)
+	}
+}
+
 func getAccountID(w http.ResponseWriter, r *http.Request) string {
 	v, ok := mux.Vars(r)["accountID"]
 	if !ok || v == "" {
diff --git a/pkg/client/README.md b/pkg/client/README.md
index a78b44b..aeb69f1 100644
--- a/pkg/client/README.md
+++ b/pkg/client/README.md
@@ -48,6 +48,7 @@ Class | Method | HTTP request | Description
 *CustomersApi* | [**DeleteCustomerAccount**](docs/CustomersApi.md#deletecustomeraccount) | **Delete** /customers/{customerID}/accounts | Delete Customer Account
 *CustomersApi* | [**GetAccountValidation**](docs/CustomersApi.md#getaccountvalidation) | **Get** /customers/{customerID}/accounts/{accountID}/validations/{validationID} | Get Account Validation
 *CustomersApi* | [**GetCustomer**](docs/CustomersApi.md#getcustomer) | **Get** /customers/{customerID} | Retrieve customer
+*CustomersApi* | [**GetCustomerAccountByID**](docs/CustomersApi.md#getcustomeraccountbyid) | **Get** /customers/{customerID}/accounts/{accountID} | Get Customer Account by ID
 *CustomersApi* | [**GetCustomerAccounts**](docs/CustomersApi.md#getcustomeraccounts) | **Get** /customers/{customerID}/accounts | Get Customer Accounts
 *CustomersApi* | [**GetCustomerDisclaimers**](docs/CustomersApi.md#getcustomerdisclaimers) | **Get** /customers/{customerID}/disclaimers | Get customer disclaimers
 *CustomersApi* | [**GetCustomerDocumentContents**](docs/CustomersApi.md#getcustomerdocumentcontents) | **Get** /customers/{customerID}/documents/{documentID} | Get customer document
diff --git a/pkg/client/api/openapi.yaml b/pkg/client/api/openapi.yaml
index 7b814ed..307e711 100644
--- a/pkg/client/api/openapi.yaml
+++ b/pkg/client/api/openapi.yaml
@@ -563,6 +563,64 @@ paths:
       summary: Create Customer Account
       tags:
       - Customers
+  /customers/{customerID}/accounts/{accountID}:
+    get:
+      description: Retrieve an account by ID for the given customer.
+      operationId: getCustomerAccountByID
+      parameters:
+      - description: Optional requestID allows application developer to trace requests
+          through the systems logs
+        example: rs4f9915
+        explode: false
+        in: header
+        name: X-Request-ID
+        required: false
+        schema:
+          type: string
+        style: simple
+      - description: Value used to separate and identify models
+        example: org342
+        explode: false
+        in: header
+        name: X-Namespace
+        required: false
+        schema:
+          type: string
+        style: simple
+      - description: customerID of the Customer to get an Account for
+        explode: false
+        in: path
+        name: customerID
+        required: true
+        schema:
+          example: e210a9d6-d755-4455-9bd2-9577ea7e1081
+          type: string
+        style: simple
+      - description: accountID of the Account
+        explode: false
+        in: path
+        name: accountID
+        required: true
+        schema:
+          example: b74d7c51
+          type: string
+        style: simple
+      responses:
+        "200":
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Account'
+          description: An active account for the customer
+        "400":
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+          description: Failed to get accounts, see error(s)
+      summary: Get Customer Account by ID
+      tags:
+      - Customers
   /customers/{customerID}/accounts/{accountID}/decrypt:
     post:
       description: |
diff --git a/pkg/client/api_customers.go b/pkg/client/api_customers.go
index 7fe0011..f43d9c9 100644
--- a/pkg/client/api_customers.go
+++ b/pkg/client/api_customers.go
@@ -1050,6 +1050,111 @@ func (a *CustomersApiService) GetCustomer(ctx _context.Context, customerID strin
 	return localVarReturnValue, localVarHTTPResponse, nil
 }
 
+// GetCustomerAccountByIDOpts Optional parameters for the method 'GetCustomerAccountByID'
+type GetCustomerAccountByIDOpts struct {
+	XRequestID optional.String
+	XNamespace optional.String
+}
+
+/*
+GetCustomerAccountByID Get Customer Account by ID
+Retrieve an account by ID for the given customer.
+ * @param ctx _context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
+ * @param customerID customerID of the Customer to get an Account for
+ * @param accountID accountID of the Account
+ * @param optional nil or *GetCustomerAccountByIDOpts - Optional Parameters:
+ * @param "XRequestID" (optional.String) -  Optional requestID allows application developer to trace requests through the systems logs
+ * @param "XNamespace" (optional.String) -  Value used to separate and identify models
+@return Account
+*/
+func (a *CustomersApiService) GetCustomerAccountByID(ctx _context.Context, customerID string, accountID string, localVarOptionals *GetCustomerAccountByIDOpts) (Account, *_nethttp.Response, error) {
+	var (
+		localVarHTTPMethod   = _nethttp.MethodGet
+		localVarPostBody     interface{}
+		localVarFormFileName string
+		localVarFileName     string
+		localVarFileBytes    []byte
+		localVarReturnValue  Account
+	)
+
+	// create path and map variables
+	localVarPath := a.client.cfg.BasePath + "/customers/{customerID}/accounts/{accountID}"
+	localVarPath = strings.Replace(localVarPath, "{"+"customerID"+"}", _neturl.QueryEscape(parameterToString(customerID, "")), -1)
+
+	localVarPath = strings.Replace(localVarPath, "{"+"accountID"+"}", _neturl.QueryEscape(parameterToString(accountID, "")), -1)
+
+	localVarHeaderParams := make(map[string]string)
+	localVarQueryParams := _neturl.Values{}
+	localVarFormParams := _neturl.Values{}
+
+	// to determine the Content-Type header
+	localVarHTTPContentTypes := []string{}
+
+	// set Content-Type header
+	localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
+	if localVarHTTPContentType != "" {
+		localVarHeaderParams["Content-Type"] = localVarHTTPContentType
+	}
+
+	// to determine the Accept header
+	localVarHTTPHeaderAccepts := []string{"application/json"}
+
+	// set Accept header
+	localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
+	if localVarHTTPHeaderAccept != "" {
+		localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
+	}
+	if localVarOptionals != nil && localVarOptionals.XRequestID.IsSet() {
+		localVarHeaderParams["X-Request-ID"] = parameterToString(localVarOptionals.XRequestID.Value(), "")
+	}
+	if localVarOptionals != nil && localVarOptionals.XNamespace.IsSet() {
+		localVarHeaderParams["X-Namespace"] = parameterToString(localVarOptionals.XNamespace.Value(), "")
+	}
+	r, err := a.client.prepareRequest(ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
+	if err != nil {
+		return localVarReturnValue, nil, err
+	}
+
+	localVarHTTPResponse, err := a.client.callAPI(r)
+	if err != nil || localVarHTTPResponse == nil {
+		return localVarReturnValue, localVarHTTPResponse, err
+	}
+
+	localVarBody, err := _ioutil.ReadAll(localVarHTTPResponse.Body)
+	localVarHTTPResponse.Body.Close()
+	if err != nil {
+		return localVarReturnValue, localVarHTTPResponse, err
+	}
+
+	if localVarHTTPResponse.StatusCode >= 300 {
+		newErr := GenericOpenAPIError{
+			body:  localVarBody,
+			error: localVarHTTPResponse.Status,
+		}
+		if localVarHTTPResponse.StatusCode == 400 {
+			var v Error
+			err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+			if err != nil {
+				newErr.error = err.Error()
+				return localVarReturnValue, localVarHTTPResponse, newErr
+			}
+			newErr.model = v
+		}
+		return localVarReturnValue, localVarHTTPResponse, newErr
+	}
+
+	err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+	if err != nil {
+		newErr := GenericOpenAPIError{
+			body:  localVarBody,
+			error: err.Error(),
+		}
+		return localVarReturnValue, localVarHTTPResponse, newErr
+	}
+
+	return localVarReturnValue, localVarHTTPResponse, nil
+}
+
 // GetCustomerAccountsOpts Optional parameters for the method 'GetCustomerAccounts'
 type GetCustomerAccountsOpts struct {
 	XRequestID optional.String
diff --git a/pkg/client/docs/CustomersApi.md b/pkg/client/docs/CustomersApi.md
index bab13c8..00def72 100644
--- a/pkg/client/docs/CustomersApi.md
+++ b/pkg/client/docs/CustomersApi.md
@@ -14,6 +14,7 @@ Method | HTTP request | Description
 [**DeleteCustomerAccount**](CustomersApi.md#DeleteCustomerAccount) | **Delete** /customers/{customerID}/accounts | Delete Customer Account
 [**GetAccountValidation**](CustomersApi.md#GetAccountValidation) | **Get** /customers/{customerID}/accounts/{accountID}/validations/{validationID} | Get Account Validation
 [**GetCustomer**](CustomersApi.md#GetCustomer) | **Get** /customers/{customerID} | Retrieve customer
+[**GetCustomerAccountByID**](CustomersApi.md#GetCustomerAccountByID) | **Get** /customers/{customerID}/accounts/{accountID} | Get Customer Account by ID
 [**GetCustomerAccounts**](CustomersApi.md#GetCustomerAccounts) | **Get** /customers/{customerID}/accounts | Get Customer Accounts
 [**GetCustomerDisclaimers**](CustomersApi.md#GetCustomerDisclaimers) | **Get** /customers/{customerID}/disclaimers | Get customer disclaimers
 [**GetCustomerDocumentContents**](CustomersApi.md#GetCustomerDocumentContents) | **Get** /customers/{customerID}/documents/{documentID} | Get customer document
@@ -508,6 +509,54 @@ No authorization required
 [[Back to README]](../README.md)
 
 
+## GetCustomerAccountByID
+
+> Account GetCustomerAccountByID(ctx, customerID, accountID, optional)
+
+Get Customer Account by ID
+
+Retrieve an account by ID for the given customer.
+
+### Required Parameters
+
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc.
+**customerID** | **string**| customerID of the Customer to get an Account for | 
+**accountID** | **string**| accountID of the Account | 
+ **optional** | ***GetCustomerAccountByIDOpts** | optional parameters | nil if no parameters
+
+### Optional Parameters
+
+Optional parameters are passed through a pointer to a GetCustomerAccountByIDOpts struct
+
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+
+
+ **xRequestID** | **optional.String**| Optional requestID allows application developer to trace requests through the systems logs | 
+ **xNamespace** | **optional.String**| Value used to separate and identify models | 
+
+### Return type
+
+[**Account**](Account.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+- **Content-Type**: Not defined
+- **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
+[[Back to Model list]](../README.md#documentation-for-models)
+[[Back to README]](../README.md)
+
+
 ## GetCustomerAccounts
 
 > []Account GetCustomerAccounts(ctx, customerID, optional)


Test patch:
diff --git a/cmd/server/accounts/router_test.go b/cmd/server/accounts/router_test.go
index 1574a0e..0296d5b 100644
--- a/cmd/server/accounts/router_test.go
+++ b/cmd/server/accounts/router_test.go
@@ -10,21 +10,25 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/moov-io/customers/internal/testclient"
-	watchman "github.com/moov-io/watchman/client"
-	"github.com/stretchr/testify/assert"
 	"net/http"
 	"net/http/httptest"
 	"strings"
 	"testing"
 
+	watchman "github.com/moov-io/watchman/client"
+	"github.com/stretchr/testify/assert"
+
+	"github.com/moov-io/customers/internal/testclient"
+
 	"github.com/moov-io/base"
+	"github.com/stretchr/testify/require"
+
 	"github.com/moov-io/customers/cmd/server/accounts/validator"
 	"github.com/moov-io/customers/cmd/server/accounts/validator/testvalidator"
+
 	"github.com/moov-io/customers/cmd/server/fed"
 	"github.com/moov-io/customers/pkg/client"
 	"github.com/moov-io/customers/pkg/secrets"
-	"github.com/stretchr/testify/require"
 
 	"github.com/go-kit/kit/log"
 	"github.com/gorilla/mux"
@@ -170,6 +174,49 @@ func TestAccountCreationRequest(t *testing.T) {
 	}
 }
 
+func TestGetAccountByID(t *testing.T) {
+	testFedClient := &fed.MockClient{}
+	repo := setupTestAccountRepository(t)
+	validations := &validator.MockRepository{}
+	keeper := secrets.TestStringKeeper(t)
+	validationStrategies := map[validator.StrategyKey]validator.Strategy{
+		{Strategy: "test", Vendor: "moov"}: testvalidator.NewStrategy(),
+	}
+
+	handler := mux.NewRouter()
+	RegisterRoutes(log.NewNopLogger(), handler, repo, validations, testFedClient, keeper, keeper, validationStrategies, createTestOFACSearcher(repo, nil))
+
+	type account struct { // wrapper to associate an account with its customerID
+		customerID string
+		*client.Account
+	}
+
+	var accounts []*account
+	var err error
+	for i := 0; i < 5; i++ {
+		accounts = append(accounts, &account{customerID: base.ID()})
+		accounts[i].Account, err = repo.createCustomerAccount(accounts[i].customerID, base.ID(), &createAccountRequest{
+			AccountNumber: fmt.Sprintf("%d", i),
+			RoutingNumber: "987654320",
+			Type:          client.CHECKING,
+		})
+		require.NoError(t, err)
+	}
+
+	for _, acct := range accounts {
+		res := httptest.NewRecorder()
+		req := httptest.NewRequest("GET", fmt.Sprintf("/customers/%s/accounts/%s", acct.customerID, acct.AccountID), nil)
+		handler.ServeHTTP(res, req)
+		res.Flush()
+
+		require.Equal(t, http.StatusOK, res.Code, res.Body.String())
+
+		var got *client.Account
+		require.NoError(t, json.NewDecoder(res.Body).Decode(&got))
+		require.Equal(t, got, acct.Account)
+	}
+}
+
 func TestRoutes__DecryptAccountNumber(t *testing.T) {
 	customerID := base.ID()