diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a048a1e..2ac596d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -292,7 +292,7 @@ jobs: BUILD_ARGS: "--load" - name: Publish Artifacts to GitHub - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4 with: name: output path: _output/** diff --git a/apis/request/v1alpha2/request_types.go b/apis/request/v1alpha2/request_types.go index b685c0f..37b4386 100644 --- a/apis/request/v1alpha2/request_types.go +++ b/apis/request/v1alpha2/request_types.go @@ -25,6 +25,11 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" ) +const ( + ExpectedResponseCheckTypeDefault = "DEFAULT" + ExpectedResponseCheckTypeCustom = "CUSTOM" +) + // RequestParameters are the configurable fields of a Request. type RequestParameters struct { // Mappings defines the HTTP mappings for different methods. @@ -44,19 +49,41 @@ type RequestParameters struct { // SecretInjectionConfig specifies the secrets receiving patches for response data. SecretInjectionConfigs []SecretInjectionConfig `json:"secretInjectionConfigs,omitempty"` + + // ExpectedResponseCheck specifies the mechanism to validate the GET response against expected value. + ExpectedResponseCheck ExpectedResponseCheck `json:"expectedResponseCheck,omitempty"` } type Mapping struct { // +kubebuilder:validation:Enum=POST;GET;PUT;DELETE - Method string `json:"method"` - Body string `json:"body,omitempty"` - URL string `json:"url"` + // Method specifies the HTTP method for the request. + Method string `json:"method"` + + // Body specifies the body of the request. + Body string `json:"body,omitempty"` + + // URL specifies the URL for the request. + URL string `json:"url"` + + // Headers specifies the headers for the request. Headers map[string][]string `json:"headers,omitempty"` } +type ExpectedResponseCheck struct { + // Type specifies the type of the expected response check. + // +kubebuilder:validation:Enum=DEFAULT;CUSTOM + Type string `json:"type,omitempty"` + + // Logic specifies the custom logic for the expected response check. + Logic string `json:"logic,omitempty"` +} + type Payload struct { + // BaseUrl specifies the base URL for the request. BaseUrl string `json:"baseUrl,omitempty"` - Body string `json:"body,omitempty"` + + // Body specifies data to be used in the request body. + Body string `json:"body,omitempty"` } // A RequestSpec defines the desired state of a Request. diff --git a/apis/request/v1alpha2/zz_generated.deepcopy.go b/apis/request/v1alpha2/zz_generated.deepcopy.go index 6301c31..4acc6f9 100644 --- a/apis/request/v1alpha2/zz_generated.deepcopy.go +++ b/apis/request/v1alpha2/zz_generated.deepcopy.go @@ -41,6 +41,21 @@ func (in *Cache) DeepCopy() *Cache { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExpectedResponseCheck) DeepCopyInto(out *ExpectedResponseCheck) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExpectedResponseCheck. +func (in *ExpectedResponseCheck) DeepCopy() *ExpectedResponseCheck { + if in == nil { + return nil + } + out := new(ExpectedResponseCheck) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Mapping) DeepCopyInto(out *Mapping) { *out = *in @@ -183,6 +198,7 @@ func (in *RequestParameters) DeepCopyInto(out *RequestParameters) { *out = make([]SecretInjectionConfig, len(*in)) copy(*out, *in) } + out.ExpectedResponseCheck = in.ExpectedResponseCheck } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RequestParameters. diff --git a/examples/sample/request.yaml b/examples/sample/request.yaml index f012736..67120e4 100644 --- a/examples/sample/request.yaml +++ b/examples/sample/request.yaml @@ -50,6 +50,22 @@ spec: - method: "DELETE" url: (.payload.baseUrl + "/" + (.response.body.id|tostring)) + # expectedResponseCheck is optional. If not specified or if the type is "DEFAULT", + # the resource is considered up to date if the GET response matches the PUT body. + # If specified, the JQ logic determines if the resource is up to date: + # - If the JQ query is false, a PUT request is sent to update the resource. + # - If true, the resource is considered up to date. + expectedResponseCheck: + type: CUSTOM + logic: | + if .response.body.password == .payload.body.password + and .response.body.age == 30 + and .response.headers."Content-Type" == ["application/json"] + and .response.headers."X-Secret-Header"[0] == "{{ response-secret:default:extracted-header-data }}" + then true + else false + end + # Secrets receiving patches from response data secretInjectionConfigs: - secretRef: diff --git a/internal/controller/disposablerequest/disposablerequest.go b/internal/controller/disposablerequest/disposablerequest.go index 4a27a07..b6ac90f 100644 --- a/internal/controller/disposablerequest/disposablerequest.go +++ b/internal/controller/disposablerequest/disposablerequest.go @@ -172,7 +172,7 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex } func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequest) error { - sensitiveBody, err := datapatcher.PatchSecretsIntoBody(ctx, c.localKube, cr.Spec.ForProvider.Body, c.logger) + sensitiveBody, err := datapatcher.PatchSecretsIntoString(ctx, c.localKube, cr.Spec.ForProvider.Body, c.logger) if err != nil { return err } @@ -195,8 +195,6 @@ func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequ HttpRequest: details.HttpRequest, } - c.patchResponseToSecret(ctx, cr, &resource.HttpResponse) - // Get the latest version of the resource before updating if err := c.localKube.Get(ctx, types.NamespacedName{Name: cr.Name, Namespace: cr.Namespace}, cr); err != nil { return errors.Wrap(err, errGetLatestVersion) @@ -204,6 +202,7 @@ func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequ if err != nil { setErr := resource.SetError(err) + c.patchResponseToSecret(ctx, cr, &resource.HttpResponse) if settingError := utils.SetRequestResourceStatus(*resource, setErr, resource.SetLastReconcileTime(), resource.SetRequestDetails()); settingError != nil { return errors.Wrap(settingError, utils.ErrFailedToSetStatus) } @@ -211,6 +210,7 @@ func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequ } if utils.IsHTTPError(resource.HttpResponse.StatusCode) { + c.patchResponseToSecret(ctx, cr, &resource.HttpResponse) if settingError := utils.SetRequestResourceStatus(*resource, resource.SetStatusCode(), resource.SetLastReconcileTime(), resource.SetHeaders(), resource.SetBody(), resource.SetRequestDetails(), resource.SetError(nil)); settingError != nil { return errors.Wrap(settingError, utils.ErrFailedToSetStatus) } @@ -223,6 +223,7 @@ func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequ return err } + c.patchResponseToSecret(ctx, cr, &resource.HttpResponse) if !isExpectedResponse { limit := utils.GetRollbackRetriesLimit(cr.Spec.ForProvider.RollbackRetriesLimit) return utils.SetRequestResourceStatus(*resource, resource.SetStatusCode(), resource.SetLastReconcileTime(), resource.SetHeaders(), resource.SetBody(), diff --git a/internal/controller/request/observe.go b/internal/controller/request/observe.go index b2d41b6..c98d241 100644 --- a/internal/controller/request/observe.go +++ b/internal/controller/request/observe.go @@ -2,21 +2,21 @@ package request import ( "context" - "fmt" "net/http" - "strings" "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/json" "github.com/crossplane-contrib/provider-http/internal/utils" "github.com/pkg/errors" ) const ( - errObjectNotFound = "object wasn't found" - errNotValidJSON = "%s is not a valid JSON string: %s" + errObjectNotFound = "object wasn't found" + errNotValidJSON = "%s is not a valid JSON string: %s" + errConvertResToMap = "failed to convert response to map" + ErrExpectedFormat = "expectedResponseCheck.Logic JQ filter should return a boolean, but returned error: %s" + errExpectedResponseCheckType = "expectedResponseCheck.Type should be either DEFAULT, CUSTOM or empty" ) type ObserveRequestDetails struct { @@ -60,56 +60,26 @@ func (c *external) isUpToDate(ctx context.Context, cr *v1alpha2.Request) (Observ } c.patchResponseToSecret(ctx, cr, &details.HttpResponse) - desiredState, err := c.desiredState(ctx, cr) - if err != nil { - if isErrorMappingNotFound(err) { - // Since there is no PUT mapping, we skip the check for its presence in the GET response. - return NewObserve(details, responseErr, true), nil - } + return c.determineResponseCheck(ctx, cr, details, responseErr) +} - // For any other error, we return a failed observation. - return FailedObserve(), err +// determineResponseCheck determines the response check based on the expectedResponseCheck.Type +func (c *external) determineResponseCheck(ctx context.Context, cr *v1alpha2.Request, details httpClient.HttpDetails, responseErr error) (ObserveRequestDetails, error) { + responseChecker := c.getResponseCheck(cr) + if responseChecker == nil { + return FailedObserve(), errors.New(errExpectedResponseCheckType) } - return c.compareResponseAndDesiredState(details, responseErr, desiredState) + return responseChecker.Check(ctx, cr, details, responseErr) } +// isObjectValidForObservation checks if the object is valid for observation func (c *external) isObjectValidForObservation(cr *v1alpha2.Request) bool { return cr.Status.Response.Body != "" && !(cr.Status.RequestDetails.Method == http.MethodPost && utils.IsHTTPError(cr.Status.Response.StatusCode)) } -func (c *external) compareResponseAndDesiredState(details httpClient.HttpDetails, err error, desiredState string) (ObserveRequestDetails, error) { - observeRequestDetails := NewObserve(details, err, false) - - if json.IsJSONString(details.HttpResponse.Body) && json.IsJSONString(desiredState) { - responseBodyMap := json.JsonStringToMap(details.HttpResponse.Body) - desiredStateMap := json.JsonStringToMap(desiredState) - observeRequestDetails.Synced = json.Contains(responseBodyMap, desiredStateMap) && utils.IsHTTPSuccess(details.HttpResponse.StatusCode) - return observeRequestDetails, nil - } - - if !json.IsJSONString(details.HttpResponse.Body) && json.IsJSONString(desiredState) { - return FailedObserve(), errors.Errorf(errNotValidJSON, "response body", details.HttpResponse.Body) - } - - if json.IsJSONString(details.HttpResponse.Body) && !json.IsJSONString(desiredState) { - return FailedObserve(), errors.Errorf(errNotValidJSON, "PUT mapping result", desiredState) - } - - observeRequestDetails.Synced = strings.Contains(details.HttpResponse.Body, desiredState) && utils.IsHTTPSuccess(details.HttpResponse.StatusCode) - return observeRequestDetails, nil -} - -func (c *external) desiredState(ctx context.Context, cr *v1alpha2.Request) (string, error) { - requestDetails, err := c.requestDetails(ctx, cr, http.MethodPut) - if err != nil { - return "", err - } - - return requestDetails.Body.Encrypted.(string), nil -} - +// 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 { @@ -118,9 +88,3 @@ func (c *external) requestDetails(ctx context.Context, cr *v1alpha2.Request, met return c.generateValidRequestDetails(ctx, cr, mapping) } - -// isErrorMappingNotFound checks if the provided error indicates that the -// mapping for an HTTP PUT request is not found. -func isErrorMappingNotFound(err error) bool { - return errors.Cause(err).Error() == fmt.Sprintf(errMappingNotFound, http.MethodPut) -} diff --git a/internal/controller/request/observe_test.go b/internal/controller/request/observe_test.go index add3044..392da35 100644 --- a/internal/controller/request/observe_test.go +++ b/internal/controller/request/observe_test.go @@ -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/crossplane-runtime/pkg/logging" "github.com/crossplane/crossplane-runtime/pkg/test" "github.com/google/go-cmp/cmp" @@ -239,3 +240,424 @@ func Test_isUpToDate(t *testing.T) { }) } } + +func Test_determineResponseCheck(t *testing.T) { + type args struct { + ctx context.Context + cr *v1alpha2.Request + details httpClient.HttpDetails + responseErr error + } + + type want struct { + result ObserveRequestDetails + err error + } + + cases := map[string]struct { + args args + want want + }{ + "DefaultResponseCheckSynced": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + ForProvider: v1alpha2.RequestParameters{ + Payload: v1alpha2.Payload{ + Body: "{\"username\": \"john_doe\", \"email\": \"john.doe@example.com\"}", + BaseUrl: "https://api.example.com/users", + }, + Mappings: []v1alpha2.Mapping{ + testPostMapping, + testGetMapping, + testDeleteMapping, + testPutMapping, + }, + ExpectedResponseCheck: v1alpha2.ExpectedResponseCheck{ + Type: v1alpha2.ExpectedResponseCheckTypeDefault, + }, + }, + }, + }, + details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"username": "john_doe_new_username"}`, + Headers: nil, + StatusCode: 200, + }, + }, + responseErr: nil, + }, + want: want{ + result: ObserveRequestDetails{ + Details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"username": "john_doe_new_username"}`, + StatusCode: 200, + }, + }, + Synced: true, + }, + err: nil, + }, + }, + "DefaultResponseCheckUnsynced": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + ForProvider: v1alpha2.RequestParameters{ + Payload: v1alpha2.Payload{ + Body: "{\"username\": \"john_doe\", \"email\": \"john.doe@example.com\"}", + BaseUrl: "https://api.example.com/users", + }, + Mappings: []v1alpha2.Mapping{ + testPostMapping, + testGetMapping, + testDeleteMapping, + testPutMapping, + }, + ExpectedResponseCheck: v1alpha2.ExpectedResponseCheck{ + Type: v1alpha2.ExpectedResponseCheckTypeDefault, + }, + }, + }, + }, + details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"username": "john_doe"}`, + Headers: nil, + StatusCode: 0, + }, + }, + responseErr: nil, + }, + want: want{ + result: ObserveRequestDetails{ + Details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"username": "john_doe"}`, + }, + }, + Synced: false, + }, + err: nil, + }, + }, + "CustomResponseCheckFails": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + ForProvider: v1alpha2.RequestParameters{ + ExpectedResponseCheck: v1alpha2.ExpectedResponseCheck{ + Type: v1alpha2.ExpectedResponseCheckTypeCustom, + Logic: `.foo == "baz"`, + }, + }, + }, + }, + details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"username": "john_doe"}`, + Headers: nil, + StatusCode: 0, + }, + }, + responseErr: nil, + }, + want: want{ + result: ObserveRequestDetails{ + Details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"username": "john_doe"}`, + }, + }, + Synced: false, + }, + err: nil, + }, + }, + "UnknownResponseCheckType": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + ForProvider: v1alpha2.RequestParameters{ + ExpectedResponseCheck: v1alpha2.ExpectedResponseCheck{ + Type: "UnknownType", + }, + }, + }, + }, + details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"username": "john_doe"}`, + Headers: nil, + StatusCode: 0, + }, + }, + responseErr: nil, + }, + want: want{ + result: ObserveRequestDetails{ + Details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"username": "john_doe"}`, + }, + }, + Synced: true, + }, + err: nil, + }, + }, + } + + for name, tc := range cases { + tc := tc // Create local copies of loop variables + + t.Run(name, func(t *testing.T) { + e := &external{ + localKube: nil, + logger: logging.NewNopLogger(), + http: nil, + } + + got, gotErr := e.determineResponseCheck(tc.args.ctx, tc.args.cr, tc.args.details, tc.args.responseErr) + + if diff := cmp.Diff(tc.want.err, gotErr, cmp.Comparer(func(x, y error) bool { + if x == nil || y == nil { + return x == y + } + return x.Error() == y.Error() + })); diff != "" { + t.Fatalf("determineResponseCheck(...): -want error, +got error: %s", diff) + } + + if diff := cmp.Diff(tc.want.result, got); diff != "" { + t.Errorf("determineResponseCheck(...): -want result, +got result: %s", diff) + } + }) + } +} + +func Test_isObjectValidForObservation(t *testing.T) { + type args struct { + cr *v1alpha2.Request + } + + type want struct { + valid bool + } + + cases := map[string]struct { + args args + want want + }{ + "ValidResponseBody": { + args: args{ + cr: &v1alpha2.Request{ + Status: v1alpha2.RequestStatus{ + Response: v1alpha2.Response{ + Body: "some response", + }, + RequestDetails: v1alpha2.Mapping{ + Method: http.MethodGet, + }, + }, + }, + }, + want: want{ + valid: true, + }, + }, + "EmptyResponseBody": { + args: args{ + cr: &v1alpha2.Request{ + Status: v1alpha2.RequestStatus{ + Response: v1alpha2.Response{ + Body: "", + }, + }, + }, + }, + want: want{ + valid: false, + }, + }, + "POSTMethodWithErrorResponse": { + args: args{ + cr: &v1alpha2.Request{ + Status: v1alpha2.RequestStatus{ + Response: v1alpha2.Response{ + Body: "some response", + StatusCode: http.StatusInternalServerError, + }, + RequestDetails: v1alpha2.Mapping{ + Method: http.MethodPost, + }, + }, + }, + }, + want: want{ + valid: false, + }, + }, + "POSTMethodWithoutErrorResponse": { + args: args{ + cr: &v1alpha2.Request{ + Status: v1alpha2.RequestStatus{ + Response: v1alpha2.Response{ + Body: "some response", + StatusCode: http.StatusOK, + }, + RequestDetails: v1alpha2.Mapping{ + Method: http.MethodPost, + }, + }, + }, + }, + want: want{ + valid: true, + }, + }, + } + + for name, tc := range cases { + tc := tc // Create local copies of loop variables + + t.Run(name, func(t *testing.T) { + e := &external{} + + got := e.isObjectValidForObservation(tc.args.cr) + + if diff := cmp.Diff(tc.want.valid, got); diff != "" { + t.Errorf("isObjectValidForObservation(...): -want valid, +got valid: %s", diff) + } + }) + } +} + +func Test_requestDetails(t *testing.T) { + type args struct { + ctx context.Context + cr *v1alpha2.Request + method string + } + + type want struct { + result requestgen.RequestDetails + err error + } + + cases := map[string]struct { + args args + want want + }{ + "ValidMappingForGET": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + ForProvider: v1alpha2.RequestParameters{ + Payload: v1alpha2.Payload{ + Body: "{\"username\": \"john_doe\", \"email\": \"john.doe@example.com\"}", + BaseUrl: "https://api.example.com/users", + }, + Mappings: []v1alpha2.Mapping{ + testGetMapping, + }, + }, + }, + }, + method: "GET", + }, + want: want{ + result: requestgen.RequestDetails{ + Url: "https://api.example.com/users/", + Body: httpClient.Data{ + Encrypted: "", + Decrypted: "", + }, + Headers: httpClient.Data{ + Encrypted: map[string][]string{}, + Decrypted: map[string][]string{}, + }, + }, + err: nil, + }, + }, + "ValidMappingForPOST": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + ForProvider: v1alpha2.RequestParameters{ + Payload: v1alpha2.Payload{ + Body: "{\"username\": \"john_doe\", \"email\": \"john.doe@example.com\"}", + BaseUrl: "https://api.example.com/users", + }, Mappings: []v1alpha2.Mapping{ + testPostMapping, + }, + }, + }, + }, + method: "POST", + }, + want: want{ + result: requestgen.RequestDetails{ + Url: "https://api.example.com/users", + Body: httpClient.Data{ + Encrypted: `{"email":"john.doe@example.com","username":"john_doe"}`, + Decrypted: `{"email":"john.doe@example.com","username":"john_doe"}`, + }, + Headers: httpClient.Data{ + Encrypted: map[string][]string{}, + Decrypted: map[string][]string{}, + }, + }, + err: nil, + }, + }, + "MappingNotFound": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + + ForProvider: v1alpha2.RequestParameters{}, + }, + }, + method: "UNKNOWN_METHOD", + }, + want: want{ + result: requestgen.RequestDetails{}, + err: errors.Errorf(errMappingNotFound, "UNKNOWN_METHOD"), + }, + }, + } + + for name, tc := range cases { + tc := tc + + t.Run(name, func(t *testing.T) { + e := &external{} + + got, gotErr := e.requestDetails(tc.args.ctx, tc.args.cr, tc.args.method) + + if diff := cmp.Diff(tc.want.err, gotErr, cmp.Comparer(func(x, y error) bool { + if x == nil || y == nil { + return x == y + } + return x.Error() == y.Error() + })); diff != "" { + t.Fatalf("requestDetails(...): -want error, +got error: %s", diff) + } + + if diff := cmp.Diff(tc.want.result, got); diff != "" { + t.Errorf("requestDetails(...): -want result, +got result: %s", diff) + } + }) + } +} diff --git a/internal/controller/request/request.go b/internal/controller/request/request.go index 369a1e0..4ea885a 100644 --- a/internal/controller/request/request.go +++ b/internal/controller/request/request.go @@ -183,6 +183,7 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex }, nil } +// 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 { @@ -233,6 +234,7 @@ func (c *external) Delete(ctx context.Context, mg resource.Managed) error { return errors.Wrap(c.deployAction(ctx, cr, http.MethodDelete), errFailedToSendHttpRequest) } +// patchResponseToSecret patches the response data to the secret based on the given Request resource and Mapping configuration. func (c *external) patchResponseToSecret(ctx context.Context, cr *v1alpha2.Request, response *httpClient.HttpResponse) { for _, ref := range cr.Spec.ForProvider.SecretInjectionConfigs { var owner metav1.Object = nil diff --git a/internal/controller/request/requestgen/request_generator.go b/internal/controller/request/requestgen/request_generator.go index 9286173..6ea8656 100644 --- a/internal/controller/request/requestgen/request_generator.go +++ b/internal/controller/request/requestgen/request_generator.go @@ -27,7 +27,7 @@ type RequestDetails struct { // GenerateRequestDetails generates request details. func GenerateRequestDetails(ctx context.Context, localKube client.Client, methodMapping v1alpha2.Mapping, forProvider v1alpha2.RequestParameters, response v1alpha2.Response, logger logging.Logger) (RequestDetails, error, bool) { - jqObject := generateRequestObject(forProvider, response) + jqObject := GenerateRequestObject(forProvider, response) url, err := generateURL(methodMapping.URL, jqObject) if err != nil { return RequestDetails{}, err, false @@ -50,9 +50,9 @@ func GenerateRequestDetails(ctx context.Context, localKube client.Client, method return RequestDetails{Body: bodyData, Url: url, Headers: headersData}, nil, true } -// generateRequestObject creates a JSON-compatible map from the specified Request's ForProvider and Response fields. +// GenerateRequestObject creates a JSON-compatible map from the specified Request's ForProvider and Response fields. // It merges the two maps, converts JSON strings to nested maps, and returns the resulting map. -func generateRequestObject(forProvider v1alpha2.RequestParameters, response v1alpha2.Response) map[string]interface{} { +func GenerateRequestObject(forProvider v1alpha2.RequestParameters, response v1alpha2.Response) map[string]interface{} { baseMap, _ := json_util.StructToMap(forProvider) statusMap, _ := json_util.StructToMap(map[string]interface{}{ "response": response, @@ -64,6 +64,7 @@ func generateRequestObject(forProvider v1alpha2.RequestParameters, response v1al return baseMap } +// IsRequestValid checks if the request details are valid. func IsRequestValid(requestDetails RequestDetails) bool { return (!strings.Contains(fmt.Sprint(requestDetails), "null")) && (requestDetails.Url != "") } @@ -95,13 +96,13 @@ func generateBody(ctx context.Context, localKube client.Client, mappingBody stri }, nil } - jqQuery := requestprocessing.ConvertStringToJQQuery(mappingBody) + jqQuery := utils.NormalizeWhitespace(mappingBody) body, err := requestprocessing.ApplyJQOnStr(jqQuery, jqObject) if err != nil { return httpClient.Data{}, err } - sensitiveBody, err := datapatcher.PatchSecretsIntoBody(ctx, localKube, body, logger) + sensitiveBody, err := datapatcher.PatchSecretsIntoString(ctx, localKube, body, logger) if err != nil { return httpClient.Data{}, err } diff --git a/internal/controller/request/requestgen/request_generator_test.go b/internal/controller/request/requestgen/request_generator_test.go index 3947ae1..880e29b 100644 --- a/internal/controller/request/requestgen/request_generator_test.go +++ b/internal/controller/request/requestgen/request_generator_test.go @@ -62,6 +62,10 @@ var ( testPutMapping, testDeleteMapping, }, + ExpectedResponseCheck: v1alpha2.ExpectedResponseCheck{ + Type: v1alpha2.ExpectedResponseCheckTypeCustom, + Logic: "logic example", + }, } ) @@ -384,6 +388,10 @@ func Test_generateRequestObject(t *testing.T) { }, want: want{ result: map[string]any{ + "expectedResponseCheck": map[string]any{ + "type": v1alpha2.ExpectedResponseCheckTypeCustom, + "logic": "logic example", + }, "mappings": []any{ map[string]any{ "body": "{ username: .payload.body.username, email: .payload.body.email }", @@ -431,7 +439,7 @@ func Test_generateRequestObject(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - got := generateRequestObject(tc.args.forProvider, tc.args.response) + got := GenerateRequestObject(tc.args.forProvider, tc.args.response) if diff := cmp.Diff(tc.want.result, got); diff != "" { t.Fatalf("generateRequestObject(...): -want result, +got result: %s", diff) } diff --git a/internal/controller/request/requestprocessing/request_processing.go b/internal/controller/request/requestprocessing/request_processing.go index 6ca2ea6..71f0712 100644 --- a/internal/controller/request/requestprocessing/request_processing.go +++ b/internal/controller/request/requestprocessing/request_processing.go @@ -2,15 +2,10 @@ package requestprocessing import ( "encoding/json" - "strings" "github.com/crossplane-contrib/provider-http/internal/jq" ) -func ConvertStringToJQQuery(input string) string { - return strings.Join(strings.Fields(input), " ") -} - // ApplyJQOnStr applies a jq query to a Request, returning the result as a string. // The function handles complex results by converting them to JSON format. func ApplyJQOnStr(jqQuery string, baseMap map[string]interface{}) (string, error) { diff --git a/internal/controller/request/requestprocessing/request_processing_test.go b/internal/controller/request/requestprocessing/request_processing_test.go index 5d0504b..8d80c81 100644 --- a/internal/controller/request/requestprocessing/request_processing_test.go +++ b/internal/controller/request/requestprocessing/request_processing_test.go @@ -46,40 +46,6 @@ var testJQObject = map[string]any{ }, } -func Test_ConvertStringToJQQuery(t *testing.T) { - type args struct { - input string - } - type want struct { - result string - } - cases := map[string]struct { - args args - want want - }{ - "Success": { - args: args{ - input: `{ - todo_name: .payload.body.name, - reminder: .payload.body.reminder, - responsible: .payload.body.responsible, - }`, - }, - want: want{ - result: `{ todo_name: .payload.body.name, reminder: .payload.body.reminder, responsible: .payload.body.responsible, }`, - }, - }, - } - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - got := ConvertStringToJQQuery(tc.args.input) - if diff := cmp.Diff(tc.want.result, got); diff != "" { - t.Fatalf("ConvertStringToJQQuery(...): -want result, +got result: %s", diff) - } - }) - } -} - func Test_ApplyJQOnStr(t *testing.T) { type args struct { jqQuery string diff --git a/internal/controller/request/response_check.go b/internal/controller/request/response_check.go new file mode 100644 index 0000000..640036a --- /dev/null +++ b/internal/controller/request/response_check.go @@ -0,0 +1,138 @@ +package request + +import ( + "context" + "fmt" + "net/http" + "strings" + + "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/responseconverter" + datapatcher "github.com/crossplane-contrib/provider-http/internal/data-patcher" + "github.com/crossplane-contrib/provider-http/internal/jq" + "github.com/crossplane-contrib/provider-http/internal/json" + "github.com/crossplane-contrib/provider-http/internal/utils" + "github.com/pkg/errors" +) + +// ResponseCheck is an interface for performing response checks. +type ResponseCheck interface { + Check(ctx context.Context, cr *v1alpha2.Request, details httpClient.HttpDetails, responseErr error) (ObserveRequestDetails, error) +} + +// DefaultResponseCheck performs a default comparison between the response and desired state. +type DefaultResponseCheck struct { + client *external +} + +func (d *DefaultResponseCheck) Check(ctx context.Context, cr *v1alpha2.Request, details httpClient.HttpDetails, responseErr error) (ObserveRequestDetails, error) { + desiredState, err := d.desiredState(ctx, cr) + if err != nil { + if isErrorMappingNotFound(err) { + return NewObserve(details, responseErr, true), nil + } + return FailedObserve(), err + } + + return d.compareResponseAndDesiredState(ctx, details, responseErr, desiredState) +} + +// compareResponseAndDesiredState compares the response and desired state to determine if they are in sync. +func (d *DefaultResponseCheck) compareResponseAndDesiredState(ctx context.Context, details httpClient.HttpDetails, responseErr error, desiredState string) (ObserveRequestDetails, error) { + observeRequestDetails := NewObserve(details, responseErr, false) + + sensitiveBody, responseErr := datapatcher.PatchSecretsIntoString(ctx, d.client.localKube, details.HttpResponse.Body, d.client.logger) + if responseErr != nil { + return FailedObserve(), responseErr + } + + sensitiveDesiredState, responseErr := datapatcher.PatchSecretsIntoString(ctx, d.client.localKube, desiredState, d.client.logger) + if responseErr != nil { + return FailedObserve(), responseErr + } + + if json.IsJSONString(sensitiveBody) && json.IsJSONString(sensitiveDesiredState) { + responseBodyMap := json.JsonStringToMap(sensitiveBody) + desiredStateMap := json.JsonStringToMap(sensitiveDesiredState) + observeRequestDetails.Synced = json.Contains(responseBodyMap, desiredStateMap) && utils.IsHTTPSuccess(details.HttpResponse.StatusCode) + return observeRequestDetails, nil + } + + if !json.IsJSONString(sensitiveBody) && json.IsJSONString(sensitiveDesiredState) { + return FailedObserve(), errors.Errorf(errNotValidJSON, "response body", details.HttpResponse.Body) + } + + if json.IsJSONString(sensitiveBody) && !json.IsJSONString(sensitiveDesiredState) { + return FailedObserve(), errors.Errorf(errNotValidJSON, "PUT mapping result", desiredState) + } + + observeRequestDetails.Synced = strings.Contains(sensitiveBody, sensitiveDesiredState) && utils.IsHTTPSuccess(details.HttpResponse.StatusCode) + return observeRequestDetails, nil +} + +// desiredState returns the desired state for a given request +func (d *DefaultResponseCheck) desiredState(ctx context.Context, cr *v1alpha2.Request) (string, error) { + requestDetails, err := d.client.requestDetails(ctx, cr, http.MethodPut) + if err != nil { + return "", err + } + + return requestDetails.Body.Encrypted.(string), nil +} + +// CustomResponseCheck performs a custom response check using JQ logic. +type CustomResponseCheck struct { + client *external +} + +func (c *CustomResponseCheck) Check(ctx context.Context, cr *v1alpha2.Request, details httpClient.HttpDetails, responseErr error) (ObserveRequestDetails, error) { + observeRequestDetails := NewObserve(details, responseErr, false) + + // Convert response to a map and apply JQ logic + response := responseconverter.HttpResponseToV1alpha1Response(details.HttpResponse) + responseMap := requestgen.GenerateRequestObject(cr.Spec.ForProvider, response) + + jqQuery := utils.NormalizeWhitespace(cr.Spec.ForProvider.ExpectedResponseCheck.Logic) + sensitiveJQQuery, err := datapatcher.PatchSecretsIntoString(ctx, c.client.localKube, jqQuery, c.client.logger) + if err != nil { + return FailedObserve(), err + } + + sensitiveResponse, err := datapatcher.PatchSecretsIntoMap(ctx, c.client.localKube, responseMap, c.client.logger) + if err != nil { + return FailedObserve(), err + } + + jsonData, _ := json.ConvertMapToJson(responseMap) + isExpected, err := jq.ParseBool(sensitiveJQQuery, sensitiveResponse) + + c.client.logger.Debug(fmt.Sprintf("Applying JQ filter %s on data %v, result is %v", jqQuery, jsonData, isExpected)) + if err != nil { + return FailedObserve(), errors.Errorf(ErrExpectedFormat, err.Error()) + } + + observeRequestDetails.Synced = isExpected + return observeRequestDetails, nil +} + +// responseCheckFactoryMap is a map that associates each check type with its corresponding factory function. +var responseCheckFactoryMap = map[string]func(*external) ResponseCheck{ + v1alpha2.ExpectedResponseCheckTypeDefault: func(c *external) ResponseCheck { return &DefaultResponseCheck{client: c} }, + v1alpha2.ExpectedResponseCheckTypeCustom: func(c *external) ResponseCheck { return &CustomResponseCheck{client: c} }, +} + +// getResponseCheck uses a map to select and return the appropriate ResponseCheck. +func (c *external) getResponseCheck(cr *v1alpha2.Request) ResponseCheck { + if factory, ok := responseCheckFactoryMap[cr.Spec.ForProvider.ExpectedResponseCheck.Type]; ok { + return factory(c) + } + return responseCheckFactoryMap[v1alpha2.ExpectedResponseCheckTypeDefault](c) +} + +// isErrorMappingNotFound checks if the provided error indicates that the +// mapping for an HTTP PUT request is not found. +func isErrorMappingNotFound(err error) bool { + return errors.Cause(err).Error() == fmt.Sprintf(errMappingNotFound, http.MethodPut) +} diff --git a/internal/controller/request/response_check_test.go b/internal/controller/request/response_check_test.go new file mode 100644 index 0000000..d044519 --- /dev/null +++ b/internal/controller/request/response_check_test.go @@ -0,0 +1,272 @@ +package request + +import ( + "context" + "testing" + + "github.com/crossplane-contrib/provider-http/apis/request/v1alpha2" + httpClient "github.com/crossplane-contrib/provider-http/internal/clients/http" + "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" +) + +func Test_DefaultResponseCheck(t *testing.T) { + type args struct { + ctx context.Context + cr *v1alpha2.Request + details httpClient.HttpDetails + responseErr error + } + + type want struct { + result ObserveRequestDetails + err error + } + + cases := map[string]struct { + args args + want want + }{ + "ValidJSONSyncedState": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{}, + details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: ``, + Headers: nil, + StatusCode: 0, + }, + }, + responseErr: nil, + }, + want: want{ + result: ObserveRequestDetails{ + Synced: true, + }, + err: nil, + }, + }, + "UnsyncedStateWithValidJSON": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + ForProvider: v1alpha2.RequestParameters{ + Payload: v1alpha2.Payload{ + Body: "{\"username\": \"john_doe\", \"email\": \"john.doe@example.com\"}", + BaseUrl: "https://api.example.com/users", + }, + Mappings: []v1alpha2.Mapping{ + testPostMapping, + testGetMapping, + testPutMapping, + testDeleteMapping, + }, + ExpectedResponseCheck: v1alpha2.ExpectedResponseCheck{ + Type: v1alpha2.ExpectedResponseCheckTypeDefault, + }, + }, + }, + }, details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"username": "john_doe"}`, + Headers: nil, + StatusCode: 0, + }, + }, + responseErr: nil, + }, + want: want{ + result: ObserveRequestDetails{ + Details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"username": "john_doe"}`, + }, + }, + Synced: false, + }, + err: nil, + }, + }, + "InvalidResponseJSON": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + ForProvider: v1alpha2.RequestParameters{ + Payload: v1alpha2.Payload{ + Body: "{\"username\": \"john_doe\", \"email\": \"john.doe@example.com\"}", + BaseUrl: "https://api.example.com/users", + }, + Mappings: []v1alpha2.Mapping{ + testPostMapping, + testGetMapping, + testPutMapping, + testDeleteMapping, + }, + ExpectedResponseCheck: v1alpha2.ExpectedResponseCheck{ + Type: v1alpha2.ExpectedResponseCheckTypeDefault, + }, + }, + }, + }, + details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{`, + Headers: nil, + StatusCode: 200, + }, + }, + responseErr: nil, + }, + want: want{ + result: ObserveRequestDetails{ + Synced: false, + }, + err: errors.New("response body is not a valid JSON string: {"), + }, + }, + } + + for name, tc := range cases { + tc := tc + + t.Run(name, func(t *testing.T) { + e := &DefaultResponseCheck{ + client: &external{ + localKube: nil, + http: nil, + logger: nil, + }, + } + got, gotErr := e.Check(tc.args.ctx, tc.args.cr, tc.args.details, tc.args.responseErr) + if diff := cmp.Diff(tc.want.err, gotErr, cmp.Comparer(func(x, y error) bool { + return x.Error() == y.Error() + })); diff != "" { + t.Fatalf("Check(...): -want error, +got error: %s", diff) + } + if diff := cmp.Diff(tc.want.result, got); diff != "" { + t.Errorf("Check(...): -want result, +got result: %s", diff) + } + }) + } +} + +func Test_CustomResponseCheck(t *testing.T) { + type args struct { + ctx context.Context + cr *v1alpha2.Request + details httpClient.HttpDetails + responseErr error + } + + type want struct { + result ObserveRequestDetails + err error + } + + cases := map[string]struct { + args args + want want + }{ + "CustomCheckPasses": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + ForProvider: v1alpha2.RequestParameters{ + Payload: v1alpha2.Payload{ + Body: `{"password": "password"}`, + }, + ExpectedResponseCheck: v1alpha2.ExpectedResponseCheck{ + Type: v1alpha2.ExpectedResponseCheckTypeCustom, + Logic: `.response.body.password == .payload.body.password`, + }, + }, + }, + }, + details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"password":"password"}`, + Headers: nil, + StatusCode: 0, + }, + }, + responseErr: nil, + }, + want: want{ + result: ObserveRequestDetails{ + Details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"password":"password"}`, + }, + }, + Synced: true, + }, + err: nil, + }, + }, + "CustomCheckFails": { + args: args{ + ctx: context.Background(), + cr: &v1alpha2.Request{ + Spec: v1alpha2.RequestSpec{ + ForProvider: v1alpha2.RequestParameters{ + Payload: v1alpha2.Payload{ + Body: `{"password": "password"}`, + }, + ExpectedResponseCheck: v1alpha2.ExpectedResponseCheck{ + Type: v1alpha2.ExpectedResponseCheckTypeCustom, + Logic: `.response.body.password == .payload.body.password`, + }, + }, + }, + }, + details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"password":"wrong_password"}`, + Headers: nil, + StatusCode: 0, + }, + }, + responseErr: nil, + }, + want: want{ + result: ObserveRequestDetails{ + Details: httpClient.HttpDetails{ + HttpResponse: httpClient.HttpResponse{ + Body: `{"password":"wrong_password"}`, + }, + }, + Synced: false, + }, + err: nil, + }, + }, + } + + for name, tc := range cases { + tc := tc // Create local copies of loop variables + + t.Run(name, func(t *testing.T) { + e := &CustomResponseCheck{ + client: &external{ + localKube: nil, + http: nil, + logger: logging.NewNopLogger(), + }, + } + got, gotErr := e.Check(tc.args.ctx, tc.args.cr, tc.args.details, tc.args.responseErr) + if diff := cmp.Diff(tc.want.err, gotErr, cmp.Comparer(func(x, y error) bool { + return x.Error() == y.Error() + })); diff != "" { + t.Fatalf("Check(...): -want error, +got error: %s", diff) + } + if diff := cmp.Diff(tc.want.result, got); diff != "" { + t.Errorf("Check(...): -want result, +got result: %s", diff) + } + }) + } +} diff --git a/internal/data-patcher/parser.go b/internal/data-patcher/parser.go index e02d216..a6f79a0 100644 --- a/internal/data-patcher/parser.go +++ b/internal/data-patcher/parser.go @@ -133,3 +133,30 @@ func patchValueToSecret(ctx context.Context, kubeClient client.Client, logger lo return kubehandler.UpdateSecret(ctx, kubeClient, secret) } + +// patchSecretsInMap traverses a map and patches secrets into any string values. +func patchSecretsInMap(ctx context.Context, localKube client.Client, data map[string]interface{}, logger logging.Logger) error { + for key, value := range data { + switch v := value.(type) { + case string: + patchedValue, err := patchSecretsToValue(ctx, localKube, v, logger) + if err != nil { + return err + } + data[key] = patchedValue + + case map[string]interface{}: + err := patchSecretsInMap(ctx, localKube, v, logger) + if err != nil { + return err + } + + case []interface{}: + err := patchSecretsInSlice(ctx, localKube, v, logger) + if err != nil { + return err + } + } + } + return nil +} diff --git a/internal/data-patcher/patch.go b/internal/data-patcher/patch.go index 4a93a55..dd2f9cf 100644 --- a/internal/data-patcher/patch.go +++ b/internal/data-patcher/patch.go @@ -15,9 +15,9 @@ const ( errPatchToReferencedSecret = "cannot patch to referenced secret" ) -// PatchSecretsIntoBody patches secrets into the provided string body. -func PatchSecretsIntoBody(ctx context.Context, localKube client.Client, body string, logger logging.Logger) (string, error) { - return patchSecretsToValue(ctx, localKube, body, logger) +// PatchSecretsIntoString patches secrets into the provided string. +func PatchSecretsIntoString(ctx context.Context, localKube client.Client, str string, logger logging.Logger) (string, error) { + return patchSecretsToValue(ctx, localKube, str, logger) } // PatchSecretsIntoHeaders takes a map of headers and applies security measures to @@ -65,3 +65,71 @@ func PatchResponseToSecret(ctx context.Context, localKube client.Client, logger return nil } + +// PatchSecretsIntoMap takes a map of string to interface{} and patches secrets +// into any string values within the map, including nested maps and slices. +func PatchSecretsIntoMap(ctx context.Context, localKube client.Client, data map[string]interface{}, logger logging.Logger) (map[string]interface{}, error) { + dataCopy := copyMap(data) + + err := patchSecretsInMap(ctx, localKube, dataCopy, logger) + if err != nil { + return nil, err + } + + return dataCopy, nil +} + +// copyMap creates a deep copy of a map[string]interface{}. +func copyMap(original map[string]interface{}) map[string]interface{} { + copy := make(map[string]interface{}, len(original)) + for k, v := range original { + copy[k] = deepCopy(v) + } + return copy +} + +// deepCopy performs a deep copy of the value, handling maps and slices recursively. +func deepCopy(value interface{}) interface{} { + switch v := value.(type) { + case map[string]interface{}: + return copyMap(v) + case []interface{}: + copy := make([]interface{}, len(v)) + for i, item := range v { + copy[i] = deepCopy(item) + } + return copy + default: + return v + } +} + +// patchSecretsInSlice traverses a slice and patches secrets into any string values. +func patchSecretsInSlice(ctx context.Context, localKube client.Client, data []interface{}, logger logging.Logger) error { + for i, item := range data { + switch v := item.(type) { + case string: + // Patch secrets in string values + patchedValue, err := patchSecretsToValue(ctx, localKube, v, logger) + if err != nil { + return err + } + data[i] = patchedValue + + case map[string]interface{}: + // Recursively patch secrets in nested maps + err := patchSecretsInMap(ctx, localKube, v, logger) + if err != nil { + return err + } + + case []interface{}: + // Recursively patch secrets in nested slices + err := patchSecretsInSlice(ctx, localKube, v, logger) + if err != nil { + return err + } + } + } + return nil +} diff --git a/internal/data-patcher/patch_test.go b/internal/data-patcher/patch_test.go index fd18463..113c55f 100644 --- a/internal/data-patcher/patch_test.go +++ b/internal/data-patcher/patch_test.go @@ -76,7 +76,7 @@ func TestPatchSecretsIntoBody(t *testing.T) { tc := tc // Create local copies of loop variables t.Run(name, func(t *testing.T) { - got, gotErr := PatchSecretsIntoBody(tc.args.ctx, tc.args.localKube, tc.args.body, logging.NewNopLogger()) + got, gotErr := PatchSecretsIntoString(tc.args.ctx, tc.args.localKube, tc.args.body, logging.NewNopLogger()) if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" { t.Fatalf("isUpToDate(...): -want error, +got error: %s", diff) } diff --git a/internal/json/util.go b/internal/json/util.go index 4bf54c9..57015cc 100644 --- a/internal/json/util.go +++ b/internal/json/util.go @@ -68,13 +68,14 @@ func StructToMap(obj interface{}) (newMap map[string]interface{}, err error) { return } -// ConvertMapToJson converts a map to a JSON byte slice. -func ConvertMapToJson(m map[string]interface{}) ([]byte, bool) { - jsonData, err := json.Marshal(m) +// ConvertMapToJson converts a map to a JSON string. +func ConvertMapToJson(m map[string]interface{}) (string, error) { + jsonBytes, err := json.Marshal(m) if err != nil { - return nil, false + return "", err } - return jsonData, true + + return string(jsonBytes), nil } // deepEqual checks if two interfaces are deeply equal by comparing their JSON representations. diff --git a/internal/json/util_test.go b/internal/json/util_test.go index ed3c698..0aeba8f 100644 --- a/internal/json/util_test.go +++ b/internal/json/util_test.go @@ -45,6 +45,10 @@ var ( testPutMapping, testDeleteMapping, }, + ExpectedResponseCheck: v1alpha2.ExpectedResponseCheck{ + Type: v1alpha2.ExpectedResponseCheckTypeCustom, + Logic: "logic example", + }, } ) @@ -269,6 +273,10 @@ func Test_StructToMap(t *testing.T) { }, want: want{ result: map[string]any{ + "expectedResponseCheck": map[string]any{ + "type": v1alpha2.ExpectedResponseCheckTypeCustom, + "logic": "logic example", + }, "mappings": []any{ map[string]any{ "body": "{ username: .payload.body.username, email: .payload.body.email }", diff --git a/internal/utils/strings.go b/internal/utils/strings.go new file mode 100644 index 0000000..45c2ef3 --- /dev/null +++ b/internal/utils/strings.go @@ -0,0 +1,10 @@ +package utils + +import ( + "strings" +) + +// NormalizeWhitespace removes extra whitespace from a string. +func NormalizeWhitespace(input string) string { + return strings.Join(strings.Fields(input), " ") +} diff --git a/internal/utils/strings_test.go b/internal/utils/strings_test.go new file mode 100644 index 0000000..6f11a28 --- /dev/null +++ b/internal/utils/strings_test.go @@ -0,0 +1,41 @@ +package utils + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func Test_ConvertStringToJQQuery(t *testing.T) { + type args struct { + input string + } + type want struct { + result string + } + cases := map[string]struct { + args args + want want + }{ + "Success": { + args: args{ + input: `{ + todo_name: .payload.body.name, + reminder: .payload.body.reminder, + responsible: .payload.body.responsible, + }`, + }, + want: want{ + result: `{ todo_name: .payload.body.name, reminder: .payload.body.reminder, responsible: .payload.body.responsible, }`, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := NormalizeWhitespace(tc.args.input) + if diff := cmp.Diff(tc.want.result, got); diff != "" { + t.Fatalf("ConvertStringToJQQuery(...): -want result, +got result: %s", diff) + } + }) + } +} diff --git a/package/crds/http.crossplane.io_requests.yaml b/package/crds/http.crossplane.io_requests.yaml index ef57d72..9c12fdf 100644 --- a/package/crds/http.crossplane.io_requests.yaml +++ b/package/crds/http.crossplane.io_requests.yaml @@ -469,6 +469,22 @@ spec: forProvider: description: RequestParameters are the configurable fields of a Request. properties: + expectedResponseCheck: + description: ExpectedResponseCheck specifies the mechanism to + validate the GET response against expected value. + properties: + logic: + description: Logic specifies the custom logic for the expected + response check. + type: string + type: + description: Type specifies the type of the expected response + check. + enum: + - DEFAULT + - CUSTOM + type: string + type: object headers: additionalProperties: items: @@ -486,14 +502,17 @@ spec: items: properties: body: + description: Body specifies the body of the request. type: string headers: additionalProperties: items: type: string type: array + description: Headers specifies the headers for the request. type: object method: + description: Method specifies the HTTP method for the request. enum: - POST - GET @@ -501,6 +520,7 @@ spec: - DELETE type: string url: + description: URL specifies the URL for the request. type: string required: - method @@ -511,8 +531,11 @@ spec: description: Payload defines the payload for the request. properties: baseUrl: + description: BaseUrl specifies the base URL for the request. type: string body: + description: Body specifies data to be used in the request + body. type: string type: object secretInjectionConfigs: @@ -815,14 +838,17 @@ spec: requestDetails: properties: body: + description: Body specifies the body of the request. type: string headers: additionalProperties: items: type: string type: array + description: Headers specifies the headers for the request. type: object method: + description: Method specifies the HTTP method for the request. enum: - POST - GET @@ -830,6 +856,7 @@ spec: - DELETE type: string url: + description: URL specifies the URL for the request. type: string required: - method