Skip to content

Commit

Permalink
feat: implement flexible http method mapping (#62)
Browse files Browse the repository at this point in the history
Signed-off-by: Ariel Septon <[email protected]>
  • Loading branch information
arielsepton authored Oct 2, 2024
1 parent 14e486a commit 2b1f71c
Show file tree
Hide file tree
Showing 12 changed files with 543 additions and 150 deletions.
14 changes: 13 additions & 1 deletion apis/request/v1alpha2/request_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ const (
ExpectedResponseCheckTypeCustom = "CUSTOM"
)

const (
ActionCreate = "CREATE"
ActionObserve = "OBSERVE"
ActionUpdate = "UPDATE"
ActionRemove = "REMOVE"
)

// RequestParameters are the configurable fields of a Request.
type RequestParameters struct {
// Mappings defines the HTTP mappings for different methods.
Expand All @@ -55,9 +62,14 @@ type RequestParameters struct {
}

type Mapping struct {
// Either Method or Action must be specified. If both are omitted, the mapping will not be used.
// +kubebuilder:validation:Enum=POST;GET;PUT;DELETE
// Method specifies the HTTP method for the request.
Method string `json:"method"`
Method string `json:"method,omitempty"`

// +kubebuilder:validation:Enum=CREATE;OBSERVE;UPDATE;REMOVE
// Action specifies the intended action for the request.
Action string `json:"action,omitempty"`

// Body specifies the body of the request.
Body string `json:"body,omitempty"`
Expand Down
16 changes: 13 additions & 3 deletions examples/sample/request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ spec:
"age": 30
}
mappings:
- method: "POST"
# Scenario 1: Action specified, method not specified (defaults to POST for CREATE)
- action: CREATE
# method: "POST"
body: |
{
username: .payload.body.username,
Expand All @@ -38,16 +40,24 @@ spec:
- ("Bearer {{ auth:default:token }}")
Extra-Header-For-Post:
- extra-value
- method: "GET"

# Scenario 2: Action specified, method not specified (defaults to GET for OBSERVE)
- action: OBSERVE
# method: "GET"
url: (.payload.baseUrl + "/" + (.response.body.id|tostring))

# Scenario 3: Method specified, action not specified (PUT implies UPDATE)
- method: "PUT"
body: |
{
email: .payload.body.email,
age: .payload.body.age
}
url: (.payload.baseUrl + "/" + (.response.body.id|tostring))
- method: "DELETE"

# Scenario 4: Action specified, method not specified (defaults to DELETE for REMOVE)
- action: REMOVE
# method: "DELETE"
url: (.payload.baseUrl + "/" + (.response.body.id|tostring))

# expectedResponseCheck is optional. If not specified or if the type is "DEFAULT",
Expand Down
20 changes: 13 additions & 7 deletions internal/controller/request/observe.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/crossplane-contrib/provider-http/apis/request/v1alpha2"
httpClient "github.com/crossplane-contrib/provider-http/internal/clients/http"
"github.com/crossplane-contrib/provider-http/internal/controller/request/requestgen"
"github.com/crossplane-contrib/provider-http/internal/controller/request/requestmapping"
"github.com/crossplane-contrib/provider-http/internal/utils"
"github.com/pkg/errors"
)
Expand Down Expand Up @@ -49,12 +50,17 @@ func (c *external) isUpToDate(ctx context.Context, cr *v1alpha2.Request) (Observ
return FailedObserve(), errors.New(errObjectNotFound)
}

requestDetails, err := c.requestDetails(ctx, cr, http.MethodGet)
mapping, err := requestmapping.GetMapping(&cr.Spec.ForProvider, v1alpha2.ActionObserve, c.logger)
if err != nil {
return FailedObserve(), err
}

details, responseErr := c.http.SendRequest(ctx, http.MethodGet, requestDetails.Url, requestDetails.Body, requestDetails.Headers, cr.Spec.ForProvider.InsecureSkipTLSVerify)
requestDetails, err := c.generateValidRequestDetails(ctx, cr, mapping)
if err != nil {
return FailedObserve(), err
}

details, responseErr := c.http.SendRequest(ctx, mapping.Method, requestDetails.Url, requestDetails.Body, requestDetails.Headers, cr.Spec.ForProvider.InsecureSkipTLSVerify)
if details.HttpResponse.StatusCode == http.StatusNotFound {
return FailedObserve(), errors.New(errObjectNotFound)
}
Expand All @@ -79,11 +85,11 @@ func (c *external) isObjectValidForObservation(cr *v1alpha2.Request) bool {
!(cr.Status.RequestDetails.Method == http.MethodPost && utils.IsHTTPError(cr.Status.Response.StatusCode))
}

// requestDetails generates the request details for a given request
func (c *external) requestDetails(ctx context.Context, cr *v1alpha2.Request, method string) (requestgen.RequestDetails, error) {
mapping, ok := getMappingByMethod(&cr.Spec.ForProvider, method)
if !ok {
return requestgen.RequestDetails{}, errors.Errorf(errMappingNotFound, method)
// requestDetails generates the request details for a given method or action.
func (c *external) requestDetails(ctx context.Context, cr *v1alpha2.Request, action string) (requestgen.RequestDetails, error) {
mapping, err := requestmapping.GetMapping(&cr.Spec.ForProvider, action, c.logger)
if err != nil {
return requestgen.RequestDetails{}, err
}

return c.generateValidRequestDetails(ctx, cr, mapping)
Expand Down
42 changes: 34 additions & 8 deletions internal/controller/request/observe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/crossplane-contrib/provider-http/apis/request/v1alpha2"
httpClient "github.com/crossplane-contrib/provider-http/internal/clients/http"
"github.com/crossplane-contrib/provider-http/internal/controller/request/requestgen"
"github.com/crossplane-contrib/provider-http/internal/controller/request/requestmapping"
"github.com/crossplane/crossplane-runtime/pkg/logging"
"github.com/crossplane/crossplane-runtime/pkg/test"
"github.com/google/go-cmp/cmp"
Expand All @@ -19,6 +20,30 @@ var (
errNotFound = errors.New(errObjectNotFound)
)

var (
testPostMapping = v1alpha2.Mapping{
Method: "POST",
Body: "{ username: .payload.body.username, email: .payload.body.email }",
URL: ".payload.baseUrl",
}

testPutMapping = v1alpha2.Mapping{
Method: "PUT",
Body: "{ username: \"john_doe_new_username\" }",
URL: "(.payload.baseUrl + \"/\" + .response.body.id)",
}

testGetMapping = v1alpha2.Mapping{
Method: "GET",
URL: "(.payload.baseUrl + \"/\" + .response.body.id)",
}

testDeleteMapping = v1alpha2.Mapping{
Method: "DELETE",
URL: "(.payload.baseUrl + \"/\" + .response.body.id)",
}
)

func Test_isUpToDate(t *testing.T) {
type args struct {
http httpClient.Client
Expand Down Expand Up @@ -537,7 +562,7 @@ func Test_requestDetails(t *testing.T) {
type args struct {
ctx context.Context
cr *v1alpha2.Request
method string
action string
}

type want struct {
Expand Down Expand Up @@ -565,7 +590,7 @@ func Test_requestDetails(t *testing.T) {
},
},
},
method: "GET",
action: v1alpha2.ActionObserve,
},
want: want{
result: requestgen.RequestDetails{
Expand Down Expand Up @@ -597,7 +622,7 @@ func Test_requestDetails(t *testing.T) {
},
},
},
method: "POST",
action: v1alpha2.ActionCreate,
},
want: want{
result: requestgen.RequestDetails{
Expand All @@ -619,15 +644,14 @@ func Test_requestDetails(t *testing.T) {
ctx: context.Background(),
cr: &v1alpha2.Request{
Spec: v1alpha2.RequestSpec{

ForProvider: v1alpha2.RequestParameters{},
},
},
method: "UNKNOWN_METHOD",
action: "UNKNOWN_METHOD",
},
want: want{
result: requestgen.RequestDetails{},
err: errors.Errorf(errMappingNotFound, "UNKNOWN_METHOD"),
err: errors.Errorf(requestmapping.ErrMappingNotFound, "UNKNOWN_METHOD", http.MethodGet),
},
},
}
Expand All @@ -636,9 +660,11 @@ func Test_requestDetails(t *testing.T) {
tc := tc

t.Run(name, func(t *testing.T) {
e := &external{}
e := &external{
logger: logging.NewNopLogger(),
}

got, gotErr := e.requestDetails(tc.args.ctx, tc.args.cr, tc.args.method)
got, gotErr := e.requestDetails(tc.args.ctx, tc.args.cr, tc.args.action)
if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" {
t.Fatalf("requestDetails(...): -want error, +got error: %s", diff)
}
Expand Down
17 changes: 8 additions & 9 deletions internal/controller/request/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package request
import (
"context"
"fmt"
"net/http"
"time"

"github.com/crossplane/crossplane-runtime/pkg/logging"
Expand All @@ -39,6 +38,7 @@ import (
apisv1alpha1 "github.com/crossplane-contrib/provider-http/apis/v1alpha1"
httpClient "github.com/crossplane-contrib/provider-http/internal/clients/http"
"github.com/crossplane-contrib/provider-http/internal/controller/request/requestgen"
"github.com/crossplane-contrib/provider-http/internal/controller/request/requestmapping"
"github.com/crossplane-contrib/provider-http/internal/controller/request/statushandler"
datapatcher "github.com/crossplane-contrib/provider-http/internal/data-patcher"
"github.com/crossplane-contrib/provider-http/internal/utils"
Expand All @@ -54,7 +54,6 @@ const (
errFailedToCheckIfUpToDate = "failed to check if request is up to date"
errFailedToUpdateStatusFailures = "failed to reset status failures counter"
errFailedUpdateStatusConditions = "failed updating status conditions"
errMappingNotFound = "%s mapping doesn't exist in request, skipping operation"
errPatchDataToSecret = "Warning, couldn't patch data from request to secret %s:%s:%s, error: %s"
errGetLatestVersion = "failed to get the latest version of the resource"
)
Expand Down Expand Up @@ -184,10 +183,10 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex
}

// deployAction executes the action based on the given Request resource and Mapping configuration.
func (c *external) deployAction(ctx context.Context, cr *v1alpha2.Request, method string) error {
mapping, ok := getMappingByMethod(&cr.Spec.ForProvider, method)
if !ok {
c.logger.Info(fmt.Sprintf(errMappingNotFound, method))
func (c *external) deployAction(ctx context.Context, cr *v1alpha2.Request, action string) error {
mapping, err := requestmapping.GetMapping(&cr.Spec.ForProvider, action, c.logger)
if err != nil {
c.logger.Info(err.Error())
return nil
}

Expand All @@ -213,7 +212,7 @@ func (c *external) Create(ctx context.Context, mg resource.Managed) (managed.Ext
return managed.ExternalCreation{}, errors.New(errNotRequest)
}

return managed.ExternalCreation{}, errors.Wrap(c.deployAction(ctx, cr, http.MethodPost), errFailedToSendHttpRequest)
return managed.ExternalCreation{}, errors.Wrap(c.deployAction(ctx, cr, v1alpha2.ActionCreate), errFailedToSendHttpRequest)
}

func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.ExternalUpdate, error) {
Expand All @@ -222,7 +221,7 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext
return managed.ExternalUpdate{}, errors.New(errNotRequest)
}

return managed.ExternalUpdate{}, errors.Wrap(c.deployAction(ctx, cr, http.MethodPut), errFailedToSendHttpRequest)
return managed.ExternalUpdate{}, errors.Wrap(c.deployAction(ctx, cr, v1alpha2.ActionUpdate), errFailedToSendHttpRequest)
}

func (c *external) Delete(ctx context.Context, mg resource.Managed) error {
Expand All @@ -231,7 +230,7 @@ func (c *external) Delete(ctx context.Context, mg resource.Managed) error {
return errors.New(errNotRequest)
}

return errors.Wrap(c.deployAction(ctx, cr, http.MethodDelete), errFailedToSendHttpRequest)
return errors.Wrap(c.deployAction(ctx, cr, v1alpha2.ActionRemove), errFailedToSendHttpRequest)
}

// patchResponseToSecret patches the response data to the secret based on the given Request resource and Mapping configuration.
Expand Down
73 changes: 73 additions & 0 deletions internal/controller/request/requestmapping/mapping.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package requestmapping

import (
"fmt"
"net/http"

"github.com/crossplane-contrib/provider-http/apis/request/v1alpha2"
"github.com/crossplane/crossplane-runtime/pkg/logging"
"github.com/pkg/errors"
)

const (
ErrMappingNotFound = "%s or %s mapping doesn't exist in request, skipping operation"
)

var (
// actionToMathodFactoryMap maps action to the default corresponding HTTP method.
actionToMathodFactoryMap = map[string]string{
v1alpha2.ActionCreate: http.MethodPost,
v1alpha2.ActionObserve: http.MethodGet,
v1alpha2.ActionUpdate: http.MethodPut,
v1alpha2.ActionRemove: http.MethodDelete,
}
)

// getMappingByMethod returns the mapping for the given method from the request parameters.
func getMappingByMethod(requestParams *v1alpha2.RequestParameters, method string) (*v1alpha2.Mapping, bool) {
for _, mapping := range requestParams.Mappings {
if mapping.Method == method {
return &mapping, true
}
}
return nil, false
}

// getMappingByAction returns the mapping for the given action from the request parameters.
func getMappingByAction(requestParams *v1alpha2.RequestParameters, action string) (*v1alpha2.Mapping, bool) {
for _, mapping := range requestParams.Mappings {
if mapping.Action == action {
return &mapping, true
}
}
return nil, false
}

// GetMapping retrieves the mapping based on the provided request parameters, method, and action.
// It first attempts to find the mapping by the specified action. If found, it sets the method if it's not defined.
// If no action is specified or the mapping by action is not found, it falls back to finding the mapping by the default method.
func GetMapping(requestParams *v1alpha2.RequestParameters, action string, logger logging.Logger) (*v1alpha2.Mapping, error) {
method := getDefaultMethodByAction(action)
if mapping, found := getMappingByAction(requestParams, action); found {
if mapping.Method == "" {
mapping.Method = method
}
return mapping, nil
}

logger.Debug(fmt.Sprintf("Mapping not found for action %s, trying to find mapping by method %s", action, method))
if mapping, found := getMappingByMethod(requestParams, method); found {
return mapping, nil
}

return nil, errors.Errorf(ErrMappingNotFound, action, method)
}

// getDefaultMethodByAction returns the default HTTP method for the given action.
func getDefaultMethodByAction(action string) string {
if defaultAction, ok := actionToMathodFactoryMap[action]; ok {
return defaultAction
}

return http.MethodGet
}
Loading

0 comments on commit 2b1f71c

Please sign in to comment.