github.com/kyma-incubator/compass/components/director@v0.0.0-20230623144113-d764f56ff805/pkg/webhook_client/client.go (about)

     1  /*
     2   * Copyright 2020 The Compass Authors
     3   *
     4   * Licensed under the Apache License, Version 2.0 (the "License");
     5   * you may not use this file except in compliance with the License.
     6   * You may obtain a copy of the License at
     7   *
     8   *     http://www.apache.org/licenses/LICENSE-2.0
     9   *
    10   * Unless required by applicable law or agreed to in writing, software
    11   * distributed under the License is distributed on an "AS IS" BASIS,
    12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13   * See the License for the specific language governing permissions and
    14   * limitations under the License.
    15   */
    16  
    17  package webhookclient
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"encoding/json"
    23  	"fmt"
    24  	"io"
    25  	"net/http"
    26  
    27  	"github.com/kyma-incubator/compass/components/director/pkg/graphql"
    28  	"github.com/kyma-incubator/compass/components/director/pkg/log"
    29  	"github.com/kyma-incubator/compass/components/director/pkg/webhook"
    30  
    31  	"github.com/kyma-incubator/compass/components/director/pkg/str"
    32  
    33  	"github.com/kyma-incubator/compass/components/director/pkg/accessstrategy"
    34  
    35  	"github.com/kyma-incubator/compass/components/director/pkg/auth"
    36  	"github.com/kyma-incubator/compass/components/director/pkg/correlation"
    37  
    38  	"github.com/pkg/errors"
    39  )
    40  
    41  const emptyBody = `{}`
    42  
    43  type client struct {
    44  	httpClient       *http.Client
    45  	mtlsClient       *http.Client
    46  	extSvcMtlsClient *http.Client
    47  }
    48  
    49  // NewClient creates a new webhook client
    50  func NewClient(httpClient *http.Client, mtlsClient, extSvcMtlsClient *http.Client) *client {
    51  	return &client{
    52  		httpClient:       httpClient,
    53  		mtlsClient:       mtlsClient,
    54  		extSvcMtlsClient: extSvcMtlsClient,
    55  	}
    56  }
    57  
    58  func (c *client) Do(ctx context.Context, request WebhookRequest) (*webhook.Response, error) {
    59  	var err error
    60  	webhook := request.GetWebhook()
    61  
    62  	if webhook.OutputTemplate == nil {
    63  		return nil, errors.Errorf("missing output template")
    64  	}
    65  
    66  	var method string
    67  	url := webhook.URL
    68  	if webhook.URLTemplate != nil {
    69  		resultURL, err := request.GetObject().ParseURLTemplate(webhook.URLTemplate)
    70  		if err != nil {
    71  			return nil, errors.Wrap(err, "unable to parse webhook URL")
    72  		}
    73  		url = resultURL.Path
    74  		method = *resultURL.Method
    75  	}
    76  
    77  	if url == nil {
    78  		return nil, errors.Errorf("missing webhook url")
    79  	}
    80  
    81  	body := []byte(emptyBody)
    82  	if webhook.InputTemplate != nil {
    83  		body, err = request.GetObject().ParseInputTemplate(webhook.InputTemplate)
    84  		if err != nil {
    85  			return nil, errors.Wrap(err, "unable to parse webhook input body")
    86  		}
    87  	}
    88  
    89  	headers := http.Header{}
    90  	if webhook.HeaderTemplate != nil {
    91  		headers, err = request.GetObject().ParseHeadersTemplate(webhook.HeaderTemplate)
    92  		if err != nil {
    93  			return nil, errors.Wrap(err, "unable to parse webhook headers")
    94  		}
    95  	}
    96  	correlationID := request.GetCorrelationID()
    97  	ctx = correlation.SaveCorrelationIDHeaderToContext(ctx, webhook.CorrelationIDKey, &correlationID)
    98  
    99  	req, err := http.NewRequestWithContext(ctx, method, *url, bytes.NewBuffer(body))
   100  	if err != nil {
   101  		return nil, err
   102  	}
   103  
   104  	req.Header = headers
   105  
   106  	resp, err := c.executeRequestWithCorrectClient(ctx, req, webhook)
   107  	if err != nil {
   108  		return nil, errors.Wrap(err, "while initially executing webhook")
   109  	}
   110  
   111  	defer func() {
   112  		if err := resp.Body.Close(); err != nil {
   113  			log.C(ctx).Error(err, "Failed to close HTTP response body")
   114  		}
   115  	}()
   116  
   117  	responseObject, err := parseResponseObject(resp)
   118  	if err != nil {
   119  		return nil, err
   120  	}
   121  	log.C(ctx).Info(fmt.Sprintf("Webhook response object: %v", *responseObject))
   122  
   123  	response, err := responseObject.ParseOutputTemplate(webhook.OutputTemplate)
   124  	if err != nil {
   125  		return nil, errors.Wrap(err, "unable to parse response into webhook output template")
   126  	}
   127  
   128  	response.ActualStatusCode = &resp.StatusCode
   129  
   130  	if err = checkForGoneStatus(resp, response.GoneStatusCode); err != nil {
   131  		return response, err
   132  	}
   133  
   134  	isLocationEmpty := response.Location == nil || *response.Location == ""
   135  	isAsyncWebhook := webhook.Mode != nil && *webhook.Mode == graphql.WebhookModeAsync
   136  
   137  	if isLocationEmpty && isAsyncWebhook {
   138  		return nil, errors.Errorf("missing location url after executing async webhook: HTTP response status %+v with body %s", resp.Status, responseObject.Body)
   139  	}
   140  
   141  	return response, checkForErr(resp, response.SuccessStatusCode, response.IncompleteStatusCode, response.Error)
   142  }
   143  
   144  func (c *client) Poll(ctx context.Context, request *PollRequest) (*webhook.ResponseStatus, error) {
   145  	var err error
   146  	webhook := request.Webhook
   147  
   148  	if webhook.StatusTemplate == nil {
   149  		return nil, errors.Errorf("missing status template")
   150  	}
   151  
   152  	headers := http.Header{}
   153  	if webhook.HeaderTemplate != nil {
   154  		headers, err = request.Object.ParseHeadersTemplate(webhook.HeaderTemplate)
   155  		if err != nil {
   156  			return nil, errors.Wrap(err, "unable to parse webhook headers")
   157  		}
   158  	}
   159  
   160  	ctx = correlation.SaveCorrelationIDHeaderToContext(ctx, webhook.CorrelationIDKey, &request.CorrelationID)
   161  
   162  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, request.PollURL, nil)
   163  	if err != nil {
   164  		return nil, err
   165  	}
   166  
   167  	req.Header = headers
   168  
   169  	resp, err := c.executeRequestWithCorrectClient(ctx, req, webhook)
   170  	if err != nil {
   171  		return nil, errors.Wrap(err, "while executing webhook for poll")
   172  	}
   173  	defer func() {
   174  		err := resp.Body.Close()
   175  		if err != nil {
   176  			log.C(ctx).Error(err, "Failed to close HTTP response body")
   177  		}
   178  	}()
   179  
   180  	responseObject, err := parseResponseObject(resp)
   181  	if err != nil {
   182  		return nil, err
   183  	}
   184  
   185  	log.C(ctx).Info(fmt.Sprintf("Webhook response object: %v", *responseObject))
   186  
   187  	response, err := responseObject.ParseStatusTemplate(webhook.StatusTemplate)
   188  	if err != nil {
   189  		return nil, errors.Wrap(err, "unable to parse response status into status template")
   190  	}
   191  
   192  	return response, checkForErr(resp, response.SuccessStatusCode, nil, response.Error)
   193  }
   194  
   195  func (c *client) executeRequestWithCorrectClient(ctx context.Context, req *http.Request, webhook graphql.Webhook) (*http.Response, error) {
   196  	if webhook.Auth != nil {
   197  		log.C(ctx).Infof("Authentication configuration is available in the webhook with ID: %q", webhook.ID)
   198  		if str.PtrStrToStr(webhook.Auth.AccessStrategy) == string(accessstrategy.CMPmTLSAccessStrategy) {
   199  			log.C(ctx).Infof("Access strategy: %q is used in the webhook authentication configuration", accessstrategy.CMPmTLSAccessStrategy)
   200  			if resp, err := c.mtlsClient.Do(req); err != nil {
   201  				return c.extSvcMtlsClient.Do(req)
   202  			} else {
   203  				return resp, err
   204  			}
   205  		} else if str.PtrStrToStr(webhook.Auth.AccessStrategy) == string(accessstrategy.OpenAccessStrategy) {
   206  			log.C(ctx).Infof("Access strategy: %q is used in the webhook authentication configuration", accessstrategy.OpenAccessStrategy)
   207  			return c.httpClient.Do(req)
   208  		} else if webhook.Auth.Credential != nil {
   209  			log.C(ctx).Info("Credentials data is used in the webhook authentication configuration")
   210  			ctx = saveToContext(ctx, webhook.Auth.Credential)
   211  			req = req.WithContext(ctx)
   212  			return c.httpClient.Do(req)
   213  		} else {
   214  			return nil, errors.New("could not determine auth flow for webhook")
   215  		}
   216  	} else {
   217  		log.C(ctx).Infof("No authentication configuration is available in the webhook with ID: %q. Executing the request with unsecured client.", webhook.ID)
   218  		return c.httpClient.Do(req)
   219  	}
   220  }
   221  
   222  func parseResponseObject(resp *http.Response) (*webhook.ResponseObject, error) {
   223  	respBody, err := io.ReadAll(resp.Body)
   224  	if err != nil {
   225  		return nil, err
   226  	}
   227  
   228  	body := make(map[string]string)
   229  	if len(respBody) > 0 {
   230  		tmpBody := make(map[string]interface{})
   231  		if err := json.Unmarshal(respBody, &tmpBody); err != nil {
   232  			return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshall HTTP response with body: %q", respBody))
   233  		}
   234  
   235  		for k, v := range tmpBody {
   236  			if v == nil {
   237  				continue
   238  			}
   239  			var value string
   240  
   241  			switch v.(type) {
   242  			case string:
   243  				value = fmt.Sprintf("%v", v)
   244  			default:
   245  				marshal, err := json.Marshal(v)
   246  				marshal = bytes.ReplaceAll(marshal, []byte("\""), []byte("\\\""))
   247  				if err != nil {
   248  					return nil, err
   249  				}
   250  				value = string(marshal)
   251  			}
   252  			body[k] = value
   253  		}
   254  	}
   255  
   256  	headers := make(map[string]string)
   257  	for key, value := range resp.Header {
   258  		headers[key] = value[0]
   259  	}
   260  
   261  	return &webhook.ResponseObject{
   262  		Headers: headers,
   263  		Body:    body,
   264  	}, nil
   265  }
   266  
   267  func checkForErr(resp *http.Response, successStatusCode, incompleteStatusCode *int, errorMessage *string) error {
   268  	var errMsg string
   269  	if *successStatusCode != resp.StatusCode && (incompleteStatusCode == nil || *incompleteStatusCode != resp.StatusCode) {
   270  		incompleteStatusCodeMsg := ""
   271  		if incompleteStatusCode != nil {
   272  			incompleteStatusCodeMsg = fmt.Sprintf(" or incomplete status code '%d'", *incompleteStatusCode)
   273  		}
   274  		errMsg += fmt.Sprintf("response success status code was not met - expected success status code '%d'%s, got '%d'", *successStatusCode, incompleteStatusCodeMsg, resp.StatusCode)
   275  	}
   276  
   277  	if errorMessage != nil && *errorMessage != "" {
   278  		errMsg += fmt.Sprintf("received error while calling external system: %s", *errorMessage)
   279  	}
   280  
   281  	if errMsg != "" {
   282  		return errors.New(errMsg)
   283  	}
   284  
   285  	return nil
   286  }
   287  
   288  func checkForGoneStatus(resp *http.Response, goneStatusCode *int) error {
   289  	if goneStatusCode != nil && resp.StatusCode == *goneStatusCode {
   290  		return NewWebhookStatusGoneErr(*goneStatusCode)
   291  	}
   292  	return nil
   293  }
   294  
   295  func saveToContext(ctx context.Context, credentialData graphql.CredentialData) context.Context {
   296  	var credentials auth.Credentials
   297  
   298  	log.C(ctx).Infof("The credentials data configurated in the webhook has type: %T", credentialData)
   299  	switch v := credentialData.(type) { // The implementation of graphql.CredentialData is done by value receiver, that's why in the switch-case we need to pass structure value, not their pointers
   300  	case graphql.BasicCredentialData:
   301  		credentials = &auth.BasicCredentials{
   302  			Username: v.Username,
   303  			Password: v.Password,
   304  		}
   305  	case graphql.OAuthCredentialData:
   306  		credentials = &auth.OAuthCredentials{
   307  			ClientID:     v.ClientID,
   308  			ClientSecret: v.ClientSecret,
   309  			TokenURL:     v.URL,
   310  		}
   311  	default:
   312  		log.C(ctx).Info("The credentials data didn't match neither \"graphql.BasicCredentialData\" or \"graphql.OAuthCredentialData\"")
   313  		return ctx
   314  	}
   315  
   316  	return auth.SaveToContext(ctx, credentials)
   317  }