From d8f689431a58e9be8a39ec124a172cc776a173ca Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem <113093783+mrafnadeem-apimatic@users.noreply.github.com> Date: Wed, 14 Feb 2024 09:54:32 +0500 Subject: [PATCH] feat: multiple authentication support (#49) This commit adds the following changes: - Public user-facing changes for multiple auth in CallBuilder's Authenticate function - Support for combining authentication credentials as AND/OR groups - Unit tests to cover this feature and new assertion utilities for testing - Updated comments - Simplified release action --------- Co-authored-by: Muhammad Haseeb Co-authored-by: Bisma Co-authored-by: Asad Ali --- .github/workflows/release.yml | 13 +- .gitignore | 2 + https/authenticationGroup.go | 73 ++++++ https/authenticationGroup_test.go | 358 ++++++++++++++++++++++++++++++ https/authenticationInterface.go | 6 + https/callBuilder.go | 52 ++--- https/callBuilder_test.go | 15 +- https/httpContext.go | 6 + https/internalError.go | 10 +- https/mockingServer.go | 21 +- utilities/assert.go | 28 +++ 11 files changed, 525 insertions(+), 59 deletions(-) create mode 100644 .gitignore create mode 100644 https/authenticationGroup.go create mode 100644 https/authenticationGroup_test.go create mode 100644 https/authenticationInterface.go create mode 100644 utilities/assert.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b13c51..7f22e09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,11 @@ -name: Create Release -run-name: ${{ github.event.inputs.Title }} (${{ github.event.inputs.Version }}) +name: Release Go Package +run-name: Publishing Package Version ${{ github.event.inputs.Version }} on: workflow_dispatch: inputs: Version: - description: "Version to be released in format: x.y.z, where x => major version, y => minor version and z => patch version" + description: "This input field requires version in format: x.y.z, where x => major version, y => minor version and z => patch version" required: true - default: "0.1.0" - Title: - description: "Title of the release" - required: true - default: "Releasing fixes for bugs" jobs: create-release: name: Creating release version ${{ github.event.inputs.Version }} @@ -31,5 +26,5 @@ jobs: uses: ncipollo/release-action@v1 with: tag: ${{ steps.tag_version.outputs.new_tag }} - name: ${{ github.event.inputs.Title }} + name: Release Version ${{ github.event.inputs.Version }} body: ${{ steps.tag_version.outputs.changelog }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6ac9c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.out +.vscode/ diff --git a/https/authenticationGroup.go b/https/authenticationGroup.go new file mode 100644 index 0000000..9fccfec --- /dev/null +++ b/https/authenticationGroup.go @@ -0,0 +1,73 @@ +package https + +import ( + "fmt" +) + +const SINGLE_AUTH = "single" +const AND_AUTH = "and" +const OR_AUTH = "or" + +type AuthGroup struct { + validAuthInterfaces []AuthInterface + innerGroups []AuthGroup + authType string + singleAuthKey string + errMessage string +} + +func NewAuth(key string) AuthGroup { + return AuthGroup{ + singleAuthKey: key, + authType: SINGLE_AUTH, + } +} + +func NewOrAuth(authGroup1, authGroup2 AuthGroup, moreAuthGroups ...AuthGroup) AuthGroup { + return AuthGroup{ + innerGroups: append([]AuthGroup{authGroup1, authGroup2}, moreAuthGroups...), + authType: OR_AUTH, + } +} + +func NewAndAuth(authGroup1, authGroup2 AuthGroup, moreAuthGroups ...AuthGroup) AuthGroup { + return AuthGroup{ + innerGroups: append([]AuthGroup{authGroup1, authGroup2}, moreAuthGroups...), + authType: AND_AUTH, + } +} + +func (ag *AuthGroup) appendIndentedError(errMsg string) { + if errMsg != "" { + ag.errMessage += "\n-> " + errMsg + } +} + +func (ag *AuthGroup) validate(authInterfaces map[string]AuthInterface) { + switch ag.authType { + case SINGLE_AUTH: + val, ok := authInterfaces[ag.singleAuthKey] + + if !ok { + ag.appendIndentedError(fmt.Sprintf("%s is undefined!", ag.singleAuthKey)) + return + } + if err := val.Validate(); err != nil { + ag.appendIndentedError(err.Error()) + return + } + ag.validAuthInterfaces = append(ag.validAuthInterfaces, val) + case AND_AUTH, OR_AUTH: + for _, innerAG := range ag.innerGroups { + innerAG.validate(authInterfaces) + + ag.validAuthInterfaces = append(ag.validAuthInterfaces, innerAG.validAuthInterfaces...) + + if ag.authType == OR_AUTH && innerAG.errMessage == "" { + ag.errMessage = "" + return + } + ag.errMessage += innerAG.errMessage + } + } +} diff --git a/https/authenticationGroup_test.go b/https/authenticationGroup_test.go new file mode 100644 index 0000000..3a62760 --- /dev/null +++ b/https/authenticationGroup_test.go @@ -0,0 +1,358 @@ +package https + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/apimatic/go-core-runtime/utilities" +) + +const API_KEY = "api-key" +const API_TOKEN = "api-token" +const API_KEY_MISSING_ERROR = API_KEY + " is empty!" +const API_TOKEN_MISSING_ERROR = API_TOKEN + " is empty!" + +type MockHeaderCredentials struct { + apiKey string +} + +func NewMockHeaderCredentials(apiKey string) *MockHeaderCredentials { + return &MockHeaderCredentials{apiKey: apiKey} +} + +func (creds *MockHeaderCredentials) Validate() error { + if creds.apiKey == "" { + return errors.New(API_KEY_MISSING_ERROR) + } + + return nil +} + +func (creds *MockHeaderCredentials) Authenticator() HttpInterceptor { + return func(req *http.Request, next HttpCallExecutor) HttpContext { + req.Header.Set(API_KEY, creds.apiKey) + return next(req) + } +} + +type MockQueryCredentials struct { + apiToken string +} + +func NewMockQueryCredentials(apiToken string) *MockQueryCredentials { + return &MockQueryCredentials{apiToken: apiToken} +} + +func (creds *MockQueryCredentials) Validate() error { + + if creds.apiToken == "" { + return errors.New(API_TOKEN_MISSING_ERROR) + } + + return nil +} + +func (creds *MockQueryCredentials) Authenticator() HttpInterceptor { + return func(req *http.Request, next HttpCallExecutor) HttpContext { + query := req.URL.Query() + query.Add(API_TOKEN, creds.apiToken) + req.URL.RawQuery = query.Encode() + return next(req) + } +} + +func AuthenticationError(errMsgs ...string) string { + var body strings.Builder + + for _, errMsg := range errMsgs { + body.WriteString("\n-> ") + body.WriteString(errMsg) + } + + authError := internalError{ + Type: AUTHENTICATION_ERROR, + Body: body.String(), + FileInfo: "callBuilder.go/Authenticate", + } + return authError.Error() +} + +const MockHeaderToken = "1234" +const MockQueryToken = "abcd" + +func getMockCallBuilderWithAuths() CallBuilder { + auths := map[string]AuthInterface{ + "header": NewMockHeaderCredentials(MockHeaderToken), + "headerEmptyVal": NewMockHeaderCredentials(""), + "query": NewMockQueryCredentials(MockQueryToken), + "queryEmptyVal": NewMockQueryCredentials(""), + } + + return GetCallBuilder(ctx, "GET", "/auth", auths) +} + +func TestErrorWhenUndefinedAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate(NewAuth("authThatDoesntExist")) + + _, err := request.Call() + + utilities.AssertError(t, err) + + expected := AuthenticationError("authThatDoesntExist is undefined!") + actual := err.Error() + + utilities.AssertEquals(t, expected, actual) +} + +func TestSuccessfulCallWhenHeaderAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate(NewAuth("header")) + + httpContext, err := request.Call() + + utilities.AssertNoError(t, err) + + header := httpContext.Request.Header + + expected := MockHeaderToken + actual := header.Get("api-key") + + utilities.AssertEquals(t, expected, actual) +} + +func TestSuccessfulCallWhenQueryAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate(NewAuth("query")) + + httpContext, err := request.Call() + + utilities.AssertNoError(t, err) + + query := httpContext.Request.URL.Query() + + expected := MockQueryToken + actual := query.Get("api-token") + + utilities.AssertEquals(t, expected, actual) +} + +func TestSuccessfulCallWhenHeaderAndQueryAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewAndAuth( + NewAuth("header"), + NewAuth("query"), + ), + ) + + httpContext, err := request.Call() + + utilities.AssertNoError(t, err) + + headerToken := httpContext.Request.Header.Get(API_KEY) + utilities.AssertEquals(t, MockHeaderToken, headerToken) + + queryToken := httpContext.Request.URL.Query().Get(API_TOKEN) + utilities.AssertEquals(t, MockQueryToken, queryToken) +} + +func TestSuccessfulCallWhenHeaderOrQueryAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewOrAuth( + NewAuth("header"), + NewAuth("query"), + ), + ) + + httpContext, err := request.Call() + + utilities.AssertNoError(t, err) + + headerToken := httpContext.Request.Header.Get(API_KEY) + queryToken := httpContext.Request.URL.Query().Get(API_TOKEN) + + if headerToken != MockHeaderToken && queryToken != MockQueryToken { + t.Errorf("Expected either header param 'api-key' with value %q"+ + " or query param 'api-token' with value %q. Got neither.", + MockHeaderToken, MockQueryToken) + } +} + +func TestSuccessfulCallWhenEmptyHeaderOrQueryAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewOrAuth( + NewAuth("headerEmptyVal"), + NewAuth("query"), + ), + ) + + httpContext, err := request.Call() + + utilities.AssertNoError(t, err) + + headerToken := httpContext.Request.Header.Get(API_KEY) + queryToken := httpContext.Request.URL.Query().Get(API_TOKEN) + + utilities.AssertEquals(t, "", headerToken) + utilities.AssertEquals(t, MockQueryToken, queryToken) +} + +func TestSuccessfulCallWhenHeaderOrMissingQueryAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewOrAuth( + NewAuth("header"), + NewAuth("queryMissing"), + ), + ) + + httpContext, err := request.Call() + + utilities.AssertNoError(t, err) + + headerToken := httpContext.Request.Header.Get(API_KEY) + queryToken := httpContext.Request.URL.Query().Get(API_TOKEN) + + utilities.AssertEquals(t, "", queryToken) + + utilities.AssertEquals(t, MockHeaderToken, headerToken) +} + +func TestSuccessfulCallWhenMissingHeaderOrQueryAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewOrAuth( + NewAuth("headerMissing"), + NewAuth("query"), + ), + ) + + httpContext, err := request.Call() + + utilities.AssertNoError(t, err) + + headerToken := httpContext.Request.Header.Get(API_KEY) + queryToken := httpContext.Request.URL.Query().Get(API_TOKEN) + + utilities.AssertEquals(t, "", headerToken) + utilities.AssertEquals(t, MockQueryToken, queryToken) +} + +func TestErrorWhenHeaderWithEmptyValueAndQueryAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewAndAuth( + NewAuth("headerEmptyVal"), + NewAuth("query"), + ), + ) + + _, err := request.Call() + + utilities.AssertError(t, err) + + expected := AuthenticationError(API_KEY_MISSING_ERROR) + actual := err.Error() + + utilities.AssertEquals(t, expected, actual) +} + +func TestErrorWhenHeaderAndQueryWithEmptyValueAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewAndAuth( + NewAuth("header"), + NewAuth("queryEmptyVal"), + ), + ) + + _, err := request.Call() + + utilities.AssertError(t, err) + + expected := AuthenticationError(API_TOKEN_MISSING_ERROR) + actual := err.Error() + + utilities.AssertEquals(t, expected, actual) +} + +func TestErrorWhenHeaderAndMissingQueryAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewAndAuth( + NewAuth("header"), + NewAuth("missingQuery"), + ), + ) + + _, err := request.Call() + + utilities.AssertError(t, err) + + expected := AuthenticationError("missingQuery is undefined!") + actual := err.Error() + + utilities.AssertEquals(t, expected, actual) +} + +func TestErrorWhenMissingHeaderAndQueryAuth(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewAndAuth( + NewAuth("missingHeader"), + NewAuth("query"), + ), + ) + + _, err := request.Call() + + utilities.AssertError(t, err) + + expected := AuthenticationError("missingHeader is undefined!") + actual := err.Error() + + utilities.AssertEquals(t, expected, actual) +} + +func TestErrorWhenHeaderOrQueryAuthBothAreMissing(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewOrAuth( + NewAuth("headerMissing"), + NewAuth("queryMissing"), + ), + ) + + _, err := request.Call() + + utilities.AssertError(t, err) + + expected := AuthenticationError("headerMissing is undefined!", "queryMissing is undefined!") + actual := err.Error() + + utilities.AssertEquals(t, expected, actual) +} + +func TestErrorWhenHeaderOrQueryAuthBothAreEmpty(t *testing.T) { + request := getMockCallBuilderWithAuths() + request.Authenticate( + NewOrAuth( + NewAuth("headerEmptyVal"), + NewAuth("queryEmptyVal"), + ), + ) + + _, err := request.Call() + + utilities.AssertError(t, err) + + expected := AuthenticationError(API_KEY_MISSING_ERROR, API_TOKEN_MISSING_ERROR) + actual := err.Error() + + utilities.AssertEquals(t, expected, actual) +} diff --git a/https/authenticationInterface.go b/https/authenticationInterface.go new file mode 100644 index 0000000..2c19e79 --- /dev/null +++ b/https/authenticationInterface.go @@ -0,0 +1,6 @@ +package https + +type AuthInterface interface { + Validate() error + Authenticator() HttpInterceptor +} diff --git a/https/callBuilder.go b/https/callBuilder.go index acde9d7..97d7a20 100644 --- a/https/callBuilder.go +++ b/https/callBuilder.go @@ -26,9 +26,6 @@ const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8" const XML_CONTENT_TYPE = "application/xml" const MULTIPART_CONTENT_TYPE = "multipart/form-data" -// Authenticator is a function type used to generate HTTP interceptors for handling authentication. -type Authenticator func(bool) HttpInterceptor - // CallBuilderFactory is a function type used to create CallBuilder instances for making API calls. type CallBuilderFactory func(ctx context.Context, httpMethod, path string) CallBuilder @@ -65,7 +62,7 @@ type CallBuilder interface { CallAsJson() (*json.Decoder, *http.Response, error) CallAsText() (string, *http.Response, error) CallAsStream() ([]byte, *http.Response, error) - Authenticate(requiresAuth bool) + Authenticate(authGroup AuthGroup) RequestRetryOption(option RequestRetryOption) } @@ -86,8 +83,7 @@ type defaultCallBuilder struct { streamBody []byte httpClient HttpClient interceptors []HttpInterceptor - requiresAuth bool - authProvider Authenticator + authProvider map[string]AuthInterface retryOption RequestRetryOption retryConfig RetryConfiguration clientError error @@ -104,7 +100,7 @@ func newDefaultCallBuilder( httpMethod, path string, baseUrlProvider baseUrlProvider, - authProvider Authenticator, + authProvider map[string]AuthInterface, retryConfig RetryConfiguration, ) *defaultCallBuilder { cb := defaultCallBuilder{ @@ -122,22 +118,25 @@ func newDefaultCallBuilder( return &cb } -// addAuthentication adds authentication interceptors to the CallBuilder. -// If authentication is required (requiresAuth is true), it invokes the authProvider function to get the HTTP interceptor -// that handles authentication. The interceptor is then added to the list of interceptors in the CallBuilder. -func (cb *defaultCallBuilder) addAuthentication() { - if cb.authProvider != nil { - cb.intercept(cb.authProvider(cb.requiresAuth)) +// Authenticate sets the authentication requirement for the API call. +// If a valid auth is given, it adds the respective authentication interceptor to the CallBuilder. +func (cb *defaultCallBuilder) Authenticate(authGroup AuthGroup) { + + authGroup.validate(cb.authProvider) + + if authGroup.errMessage != "" { + cb.clientError = internalError{ + Type: AUTHENTICATION_ERROR, + Body: authGroup.errMessage, + FileInfo: "callBuilder.go/Authenticate", + } + return } -} -// Authenticate sets the authentication requirement for the API call. -// If requiresAuth is true, it adds the authentication interceptor to the CallBuilder. -func (cb *defaultCallBuilder) Authenticate(requiresAuth bool) { - cb.requiresAuth = requiresAuth - if cb.requiresAuth { - cb.addAuthentication() + for _, authI := range authGroup.validAuthInterfaces { + cb.intercept(authI.Authenticator()) } + } // RequestRetryOption sets the retry option for the API call. @@ -481,6 +480,11 @@ func (cb *defaultCallBuilder) toRequest() (*http.Request, error) { // Call executes the API call and returns the HttpContext that contains the request and response. // It iterates through the interceptors to execute them in sequence before making the API call. func (cb *defaultCallBuilder) Call() (*HttpContext, error) { + // return any client errors found before executing the call + if cb.clientError != nil { + return nil, cb.clientError + } + f := func(request *http.Request) HttpContext { client := cb.httpClient response, err := client.Execute(request) @@ -652,13 +656,11 @@ func encodeSpace(str string) string { return strings.ReplaceAll(str, "+", "%20") } -// CreateCallBuilderFactory creates a new CallBuilderFactory based on the provided parameters. -// It takes a baseUrlProvider, Authenticator, HttpClient, and RetryConfiguration as inputs. -// The returned CallBuilderFactory function creates a new CallBuilder with the provided -// context, httpMethod, and path using the inputs. +// CreateCallBuilderFactory creates a new CallBuilderFactory function which +// creates a new CallBuilder using the provided inputs func CreateCallBuilderFactory( baseUrlProvider baseUrlProvider, - auth Authenticator, + auth map[string]AuthInterface, httpClient HttpClient, retryConfig RetryConfiguration, ) CallBuilderFactory { diff --git a/https/callBuilder_test.go b/https/callBuilder_test.go index 6d03bba..cd20581 100644 --- a/https/callBuilder_test.go +++ b/https/callBuilder_test.go @@ -11,7 +11,7 @@ import ( var ctx context.Context = context.Background() -func GetCallBuilder(ctx context.Context, method, path string, auth Authenticator) CallBuilder { +func GetCallBuilder(ctx context.Context, method, path string, auth map[string]AuthInterface) CallBuilder { client := NewHttpClient(NewHttpConfiguration()) callBuilder := CreateCallBuilderFactory( func(server string) string { @@ -25,10 +25,8 @@ func GetCallBuilder(ctx context.Context, method, path string, auth Authenticator return callBuilder(ctx, method, path) } -func RequestAuthentication() Authenticator { - return func(requiresAuth bool) HttpInterceptor { - return PassThroughInterceptor - } +func RequestAuthentication() HttpInterceptor { + return PassThroughInterceptor } func TestAppendPath(t *testing.T) { @@ -259,11 +257,6 @@ func TestQueryParams(t *testing.T) { } } -func TestAuthenticate(t *testing.T) { - request := GetCallBuilder(ctx, "GET", "/auth", RequestAuthentication()) - request.Authenticate(true) -} - func TestFormData(t *testing.T) { request := GetCallBuilder(ctx, "GET", "", nil) formFields := []FormParam{ @@ -349,7 +342,7 @@ func TestRequestRetryOption(t *testing.T) { } } -func TestContextPropagationInRequests(t *testing.T) { +func TestContextPropagationInRequests(t *testing.T) { key := "Test Key" ctx = context.WithValue(ctx, &key, "Test Value") request := GetCallBuilder(ctx, "GET", "", nil) diff --git a/https/httpContext.go b/https/httpContext.go index 5f06022..7140d9f 100644 --- a/https/httpContext.go +++ b/https/httpContext.go @@ -9,3 +9,9 @@ type HttpContext struct { Request *http.Request Response *http.Response } + +func AddQuery(req *http.Request, key, value string) { + queryVal := req.URL.Query() + queryVal.Add(key, value) + req.URL.RawQuery = encodeSpace(queryVal.Encode()) +} \ No newline at end of file diff --git a/https/internalError.go b/https/internalError.go index 497f933..e05a77e 100644 --- a/https/internalError.go +++ b/https/internalError.go @@ -2,14 +2,22 @@ package https import "fmt" +const INTERNAL_ERROR = "Internal Error" +const AUTHENTICATION_ERROR = "Authentication Error" + // internalError represents a custom error type that provides additional information // about internal errors that occur within the HTTP calling code. type internalError struct { Body string FileInfo string + Type string } // Error returns a formatted error string that includes the file information and the descriptive error message. func (e internalError) Error() string { - return fmt.Sprintf("Internal Error occured at %v \n %v", e.FileInfo, e.Body) + if e.Type == AUTHENTICATION_ERROR { + return fmt.Sprintf("%v occured at %v due to following errors:%v", e.Type, e.FileInfo, e.Body) + } + + return fmt.Sprintf("%v occured at %v \n %v", INTERNAL_ERROR, e.FileInfo, e.Body) } diff --git a/https/mockingServer.go b/https/mockingServer.go index 324405c..c5462de 100644 --- a/https/mockingServer.go +++ b/https/mockingServer.go @@ -8,23 +8,18 @@ import ( // GetTestingServer creates and returns an httptest.Server instance for testing purposes. func GetTestingServer() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" { - if r.URL.Path == "/response/integer" { - w.WriteHeader(http.StatusOK) + switch r.Method { + case "GET": + switch r.URL.Path { + case "/response/integer": w.Write([]byte(`4`)) - - } else if r.URL.Path == "/template/abc/def" || r.URL.Path == "/template/1/2/3/4/5" { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`"passed": true, - "message": "It's a hit!",`)) - } else if r.URL.Path == "/response/binary" { - w.WriteHeader(http.StatusOK) + case "/template/abc/def", "/template/1/2/3/4/5", "/response/binary": w.Write([]byte(`"passed": true, "message": "It's a hit!",`)) } - } else if r.Method == "POST" { - if r.URL.Path == "/form/string" { - w.WriteHeader(http.StatusOK) + case "POST": + switch r.URL.Path { + case "/form/string": w.Write([]byte(`4`)) } } diff --git a/utilities/assert.go b/utilities/assert.go new file mode 100644 index 0000000..9d75055 --- /dev/null +++ b/utilities/assert.go @@ -0,0 +1,28 @@ +package utilities + +import "testing" + +// Asserts that both values are equal. +// This may not apply to all types. +func AssertEquals[T comparable](t *testing.T, expected, actual T) { + if expected == actual { + return + } + t.Errorf("\n----------EXPECTED----------\n%v\n------------GOT------------\n%v", expected, actual) +} + +// Asserts that an error is returned (i.e. not nil). +func AssertError(t *testing.T, err error) { + if err != nil { + return + } + t.Errorf("Expected an error") +} + +// Asserts that no error is returned (i.e. nil). +func AssertNoError(t *testing.T, err error) { + if err == nil { + return + } + t.Fatalf("Unexpected Error: %s", err.Error()) +}