Skip to content

Commit

Permalink
Insert middleware to allow org and prod id on requests (#142)
Browse files Browse the repository at this point in the history
* 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 <paras.prajapati@hashicorp.com>
Co-authored-by: Brenna Hewer-Darroch <21015366+bcmdarroch@users.noreply.github.com>
  • Loading branch information
3 people committed Dec 20, 2022
1 parent 04fd8cf commit 1d28ede
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 11 deletions.
7 changes: 7 additions & 0 deletions .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
```
6 changes: 6 additions & 0 deletions config/hcp.go
Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions config/new_test.go
Expand Up @@ -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 {
Expand Down
26 changes: 15 additions & 11 deletions httpclient/httpclient.go
Expand Up @@ -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) {
Expand All @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions httpclient/httpclient_test.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
49 changes: 49 additions & 0 deletions 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)
}

0 comments on commit 1d28ede

Please sign in to comment.