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 }