From 1d28ede287c2dfaa344ebbb8a7b42740b9ff1be9 Mon Sep 17 00:00:00 2001 From: JolisaBrownHashiCorp <99266102+JolisaBrownHashiCorp@users.noreply.github.com> Date: Tue, 20 Dec 2022 15:10:15 -0500 Subject: [PATCH] Insert middleware to allow org and prod id on requests (#142) * add integration test for middleware * WIP try to edit URL * roundtripper updates URL with profile org/proj IDs * tweak middleware test * fix linter * refactor roundtripper, make profile integration test local only * update changelog * refactor tests to cover profile and source channel middlewares * WIP - adding middleware function signature as struct field * (wip) working middleware funcs implementation * roundtripper now has array of Middleware functions * add proj ID/org ID validation * update middleware test * remove unnecessary httpclient test * move middleware into its own file for organization * withProfile -> withOrgAndProjectIDs * update changelog entry * update changelog (again) Co-authored-by: Paras Prajapati Co-authored-by: Brenna Hewer-Darroch <21015366+bcmdarroch@users.noreply.github.com> --- .changelog/142.txt | 7 +++++ config/hcp.go | 6 +++++ config/new_test.go | 20 ++++++++++++++ httpclient/httpclient.go | 26 +++++++++++-------- httpclient/httpclient_test.go | 41 +++++++++++++++++++++++++++++ httpclient/middleware.go | 49 +++++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 .changelog/142.txt create mode 100644 httpclient/middleware.go diff --git a/.changelog/142.txt b/.changelog/142.txt new file mode 100644 index 00000000..43daa409 --- /dev/null +++ b/.changelog/142.txt @@ -0,0 +1,7 @@ +```release-note:improvement +Add middleware support to httpclient package +``` + +```release-note:improvement +Add middleware that gets org ID and project ID from user profile and sets on request +``` diff --git a/config/hcp.go b/config/hcp.go index f5ff5a1d..a984df2e 100644 --- a/config/hcp.go +++ b/config/hcp.go @@ -138,6 +138,12 @@ func (c *hcpConfig) validate() error { return fmt.Errorf("either client credentials or oauth2 client ID must be provided") } + // Ensure profile contains both org ID and project ID + if (c.profile.OrganizationID == "" && c.profile.ProjectID != "") || + (c.profile.OrganizationID != "" && c.profile.ProjectID == "") { + return fmt.Errorf("when setting a user profile, both organization ID and project ID must be provided") + } + // Ensure the auth URL is valid if c.authURL.Host == "" { return fmt.Errorf("the auth URL has to be non-empty") diff --git a/config/new_test.go b/config/new_test.go index 98ab0563..e0eac192 100644 --- a/config/new_test.go +++ b/config/new_test.go @@ -111,6 +111,26 @@ func TestNew_Invalid(t *testing.T) { }, expectedError: "the configuration is not valid: the SCADA address has to be non-empty", }, + { + name: "empty project ID with populated org ID", + options: []HCPConfigOption{ + WithClientCredentials("my-client-id", "my-client-secret"), + WithProfile(&profile.UserProfile{ + OrganizationID: "abc123", + }), + }, + expectedError: "the configuration is not valid: when setting a user profile, both organization ID and project ID must be provided", + }, + { + name: "empty org ID with populated project ID", + options: []HCPConfigOption{ + WithClientCredentials("my-client-id", "my-client-secret"), + WithProfile(&profile.UserProfile{ + ProjectID: "abc123", + }), + }, + expectedError: "the configuration is not valid: when setting a user profile, both organization ID and project ID must be provided", + }, } for _, testCase := range testCases { diff --git a/httpclient/httpclient.go b/httpclient/httpclient.go index 00b5cc62..202a73b0 100644 --- a/httpclient/httpclient.go +++ b/httpclient/httpclient.go @@ -51,15 +51,6 @@ type Config struct { // Deprecated: HCPConfig should be used instead Client *http.Client } -type roundTripperWithSourceChannel struct { - OriginalRoundTripper http.RoundTripper - SourceChannel string -} - -func (rt *roundTripperWithSourceChannel) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Set("X-HCP-Source-Channel", rt.SourceChannel) - return rt.OriginalRoundTripper.RoundTrip(req) -} // New creates a client with the right base path to connect to any HCP API func New(cfg Config) (runtime *httptransport.Runtime, err error) { @@ -78,10 +69,23 @@ func New(cfg Config) (runtime *httptransport.Runtime, err error) { Source: cfg, } + var opts []MiddlewareOption + if cfg.SourceChannel != "" { // Use custom transport in order to set the source channel header when it is present. - sourceChannel := fmt.Sprintf("%s hcp-go-sdk/%s", cfg.SourceChannel, version.Version) - transport = &roundTripperWithSourceChannel{OriginalRoundTripper: transport, SourceChannel: sourceChannel} + sc := fmt.Sprintf("%s hcp-go-sdk/%s", cfg.SourceChannel, version.Version) + + opts = append(opts, withSourceChannel(sc)) + } + + if cfg.Profile().OrganizationID != "" && cfg.Profile().ProjectID != "" { + + opts = append(opts, withOrgAndProjectIDs(cfg.Profile().OrganizationID, cfg.Profile().ProjectID)) + } + + transport = &roundTripperWithMiddleware{ + OriginalRoundTripper: transport, + MiddlewareOptions: opts, } // Set the scheme based on the TLS configuration. diff --git a/httpclient/httpclient_test.go b/httpclient/httpclient_test.go index 083065f2..9938d793 100644 --- a/httpclient/httpclient_test.go +++ b/httpclient/httpclient_test.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" consul "github.com/hashicorp/hcp-sdk-go/clients/cloud-consul-service/stable/2021-02-04/client/consul_service" @@ -151,4 +152,44 @@ func TestNew(t *testing.T) { // just skip all the assertions! require.Equal(t, uint32(2), atomic.LoadUint32(&numRequests)) }) + +} + +func TestMiddleware(t *testing.T) { + + // Start with a plain request. + request, err := http.NewRequest("GET", "api.cloud.hashicorp.com/consul/2021-02-04/organizations//projects//clusters", httptest.NewRecorder().Body) + require.NoError(t, err) + + // Prepare header is unset. + require.Equal(t, request.Header.Get("X-HCP-Source-Channel"), "") + + // Prepare middleware function. + expectedSourceChannel := "source_channel_foo" + sourceChannelMiddleware := withSourceChannel(expectedSourceChannel) + + // Apply middleware function. + err = sourceChannelMiddleware(request) + require.NoError(t, err) + + // Assert request is modified as expected. + assert.Equal(t, request.Header.Get("X-HCP-Source-Channel"), expectedSourceChannel) + + // Assert path is unmodified. + expectedOrgID := "org_id_77" + expectedProjID := "proj_id_123" + assert.NotContains(t, request.URL.Path, expectedOrgID) + assert.NotContains(t, request.URL.Path, expectedProjID) + + // Prepare middleware function. + profileMiddleware := withOrgAndProjectIDs(expectedOrgID, expectedProjID) + + // Apply middleware function. + err = profileMiddleware(request) + require.NoError(t, err) + + // Assert request is modified as expected. + assert.Contains(t, request.URL.Path, expectedOrgID) + assert.Contains(t, request.URL.Path, expectedProjID) + assert.Equal(t, request.Header.Get("X-HCP-Source-Channel"), expectedSourceChannel) } diff --git a/httpclient/middleware.go b/httpclient/middleware.go new file mode 100644 index 00000000..d3150fb4 --- /dev/null +++ b/httpclient/middleware.go @@ -0,0 +1,49 @@ +package httpclient + +import ( + "fmt" + "net/http" + "strings" +) + +// MiddlewareOption is a function that modifies an HTTP request. +type MiddlewareOption = func(req *http.Request) error + +// roundTripperWithMiddleware takes a plain Roundtripper and an array of MiddlewareOptions to apply to the Roundtripper's request. +type roundTripperWithMiddleware struct { + OriginalRoundTripper http.RoundTripper + MiddlewareOptions []MiddlewareOption +} + +// withSourceChannel updates the request header to include the HCP Go SDK source channel stamp. +func withSourceChannel(sourceChannel string) MiddlewareOption { + return func(req *http.Request) error { + req.Header.Set("X-HCP-Source-Channel", sourceChannel) + return nil + } +} + +// withProfile takes the user profile's org ID and project ID and sets them in the request path if needed. +func withOrgAndProjectIDs(orgID, projID string) MiddlewareOption { + return func(req *http.Request) error { + path := req.URL.Path + path = strings.Replace(path, "organizations//", fmt.Sprintf("organizations/%s/", orgID), 1) + path = strings.Replace(path, "projects//", fmt.Sprintf("projects/%s/", projID), 1) + req.URL.Path = path + return nil + } +} + +// RoundTrip attaches MiddlewareOption modifications to the request before sending along. +func (rt *roundTripperWithMiddleware) RoundTrip(req *http.Request) (*http.Response, error) { + + for _, mw := range rt.MiddlewareOptions { + if err := mw(req); err != nil { + // Failure to apply middleware should not fail the request + fmt.Printf("failed to apply middleware: %#v", mw(req)) + continue + } + } + + return rt.OriginalRoundTripper.RoundTrip(req) +}