github.com/kyma-incubator/compass/components/director@v0.0.0-20230623144113-d764f56ff805/internal/destinationfetchersvc/client.go (about) 1 package destinationfetchersvc 2 3 import ( 4 "context" 5 "crypto/tls" 6 "encoding/json" 7 "fmt" 8 "io" 9 "net/http" 10 "net/url" 11 "strconv" 12 "strings" 13 "time" 14 15 "github.com/avast/retry-go/v4" 16 "github.com/kyma-incubator/compass/components/director/internal/model" 17 "github.com/kyma-incubator/compass/components/director/pkg/apperrors" 18 "github.com/kyma-incubator/compass/components/director/pkg/config" 19 "github.com/kyma-incubator/compass/components/director/pkg/correlation" 20 "github.com/kyma-incubator/compass/components/director/pkg/log" 21 "github.com/kyma-incubator/compass/components/director/pkg/resource" 22 "github.com/pkg/errors" 23 ) 24 25 const ( 26 correlationIDPrefix = "sap.s4:communicationScenario:" 27 s4HANAType = "SAP S/4HANA Cloud" 28 s4HANABaseURLSuffix = "-api" 29 authorizationHeader = "Authorization" 30 ) 31 32 // DestinationServiceAPIConfig destination service api configuration 33 type DestinationServiceAPIConfig struct { 34 GoroutineLimit int64 `envconfig:"APP_DESTINATIONS_SENSITIVE_GOROUTINE_LIMIT,default=10"` 35 RetryInterval time.Duration `envconfig:"APP_DESTINATIONS_RETRY_INTERVAL,default=100ms"` 36 RetryAttempts uint `envconfig:"APP_DESTINATIONS_RETRY_ATTEMPTS,default=3"` 37 EndpointGetTenantDestinations string `envconfig:"APP_ENDPOINT_GET_TENANT_DESTINATIONS,default=/destination-configuration/v1/subaccountDestinations"` 38 EndpointFindDestination string `envconfig:"APP_ENDPOINT_FIND_DESTINATION,default=/destination-configuration/v1/destinations"` 39 Timeout time.Duration `envconfig:"APP_DESTINATIONS_TIMEOUT,default=5s"` 40 PageSize int `envconfig:"APP_DESTINATIONS_PAGE_SIZE,default=100"` 41 PagingPageParam string `envconfig:"APP_DESTINATIONS_PAGE_PARAM,default=$page"` 42 PagingSizeParam string `envconfig:"APP_DESTINATIONS_PAGE_SIZE_PARAM,default=$pageSize"` 43 PagingCountParam string `envconfig:"APP_DESTINATIONS_PAGE_COUNT_PARAM,default=$pageCount"` 44 PagingCountHeader string `envconfig:"APP_DESTINATIONS_PAGE_COUNT_HEADER,default=Page-Count"` 45 SkipSSLVerify bool `envconfig:"APP_DESTINATIONS_SKIP_SSL_VERIFY,default=false"` 46 OAuthTokenPath string `envconfig:"APP_DESTINATION_OAUTH_TOKEN_PATH,default=/oauth/token"` 47 ResponseCorrelationIDHeader string `envconfig:"APP_DESTINATIONS_RESPONSE_CORRELATION_ID_HEADER,default=x-vcap-request-id"` 48 } 49 50 // Client destination client 51 type Client struct { 52 HTTPClient *http.Client 53 apiConfig DestinationServiceAPIConfig 54 authConfig config.InstanceConfig 55 authToken string 56 authTokenValidity time.Time 57 } 58 59 // Close closes idle connections 60 func (c *Client) Close() { 61 c.HTTPClient.CloseIdleConnections() 62 } 63 64 // destinationFromService destination received from destination service 65 type destinationFromService struct { 66 Name string `json:"Name"` 67 Type string `json:"Type"` 68 URL string `json:"URL"` 69 Authentication string `json:"Authentication"` 70 XFSystemName string `json:"XFSystemName"` 71 CommunicationScenarioID string `json:"communicationScenarioId"` 72 ProductName string `json:"product.name"` 73 XCorrelationID string `json:"x-correlation-id"` 74 XSystemTenantID string `json:"x-system-id"` 75 XSystemTenantName string `json:"x-system-name"` 76 XSystemType string `json:"x-system-type"` 77 XSystemBaseURL string `json:"x-system-base-url"` 78 } 79 80 func (d *destinationFromService) setDefaults(result *model.DestinationInput) error { 81 // Set values from custom properties 82 if result.XSystemType == "" { 83 result.XSystemType = d.ProductName 84 } 85 if result.XSystemType != s4HANAType { 86 return nil 87 } 88 if result.XCorrelationID == "" { 89 if d.CommunicationScenarioID != "" { 90 result.XCorrelationID = correlationIDPrefix + d.CommunicationScenarioID 91 } 92 } 93 if result.XSystemTenantName == "" { 94 result.XSystemTenantName = d.XFSystemName 95 } 96 if result.XSystemBaseURL != "" || result.URL == "" { 97 return nil 98 } 99 100 baseURL, err := url.Parse(result.URL) 101 if err != nil { 102 return errors.Wrapf(err, "%s destination has invalid URL '%s'", s4HANAType, result.URL) 103 } 104 subdomains := strings.Split(baseURL.Hostname(), ".") 105 if len(subdomains) < 2 { 106 return fmt.Errorf( 107 "%s destination has invalid URL '%s'. Expected at least 2 subdomains", s4HANAType, result.URL) 108 } 109 subdomains[0] = strings.TrimSuffix(subdomains[0], s4HANABaseURLSuffix) 110 111 result.XSystemBaseURL = fmt.Sprintf("%s://%s", baseURL.Scheme, strings.Join(subdomains, ".")) 112 return nil 113 } 114 115 // ToModel missing godoc 116 func (d *destinationFromService) ToModel() (model.DestinationInput, error) { 117 result := model.DestinationInput{ 118 Name: d.Name, 119 Type: d.Type, 120 URL: d.URL, 121 Authentication: d.Authentication, 122 XCorrelationID: d.XCorrelationID, 123 XSystemTenantID: d.XSystemTenantID, 124 XSystemTenantName: d.XSystemTenantName, 125 XSystemType: d.XSystemType, 126 XSystemBaseURL: d.XSystemBaseURL, 127 } 128 129 if err := d.setDefaults(&result); err != nil { 130 return model.DestinationInput{}, err 131 } 132 133 return result, result.Validate() 134 } 135 136 // DestinationResponse paged response from destination service 137 type DestinationResponse struct { 138 destinations []destinationFromService 139 pageCount int 140 } 141 142 func getHTTPClient(instanceConfig config.InstanceConfig, apiConfig DestinationServiceAPIConfig) (*http.Client, error) { 143 cert, err := tls.X509KeyPair([]byte(instanceConfig.Cert), []byte(instanceConfig.Key)) 144 if err != nil { 145 return nil, errors.Errorf("failed to create destinations client x509 pair: %v", err) 146 } 147 return &http.Client{ 148 Transport: &http.Transport{ 149 TLSClientConfig: &tls.Config{ 150 InsecureSkipVerify: apiConfig.SkipSSLVerify, 151 Certificates: []tls.Certificate{cert}, 152 }, 153 }, 154 Timeout: apiConfig.Timeout, 155 }, nil 156 } 157 158 func setInstanceConfigTokenURLForSubdomain(instanceConfig *config.InstanceConfig, 159 apiConfig DestinationServiceAPIConfig, subdomain string) error { 160 baseTokenURL, err := url.Parse(instanceConfig.TokenURL) 161 if err != nil { 162 return errors.Errorf("failed to parse auth url '%s': %v", instanceConfig.TokenURL, err) 163 } 164 parts := strings.Split(baseTokenURL.Hostname(), ".") 165 if len(parts) < 2 { 166 return errors.Errorf("auth url '%s' should have a subdomain", instanceConfig.TokenURL) 167 } 168 originalSubdomain := parts[0] 169 170 instanceConfig.TokenURL = strings.Replace(instanceConfig.TokenURL, originalSubdomain, subdomain, 1) + 171 apiConfig.OAuthTokenPath 172 173 return nil 174 } 175 176 // NewClient returns new destination client 177 func NewClient(instanceConfig config.InstanceConfig, apiConfig DestinationServiceAPIConfig, 178 subdomain string) (*Client, error) { 179 if err := setInstanceConfigTokenURLForSubdomain(&instanceConfig, apiConfig, subdomain); err != nil { 180 return nil, err 181 } 182 httpClient, err := getHTTPClient(instanceConfig, apiConfig) 183 if err != nil { 184 return nil, err 185 } 186 187 return &Client{ 188 HTTPClient: httpClient, 189 apiConfig: apiConfig, 190 authConfig: instanceConfig, 191 }, nil 192 } 193 194 // FetchTenantDestinationsPage returns a page of destinations 195 func (c *Client) FetchTenantDestinationsPage(ctx context.Context, tenantID, page string) (*DestinationResponse, error) { 196 fetchURL := c.authConfig.URL + c.apiConfig.EndpointGetTenantDestinations 197 req, err := c.buildFetchRequest(ctx, fetchURL, page) 198 if err != nil { 199 return nil, err 200 } 201 202 destinationsPageCallStart := time.Now() 203 res, err := c.sendRequestWithRetry(req) 204 if err != nil { 205 return nil, err 206 } 207 defer func() { 208 if err := res.Body.Close(); err != nil { 209 log.C(ctx).WithError(err).Error("Unable to close response body") 210 } 211 }() 212 213 if res.StatusCode != http.StatusOK { 214 return nil, errors.Errorf("received status code %d when trying to fetch destinations", res.StatusCode) 215 } 216 217 destinationsPageCallHeadersDuration := time.Since(destinationsPageCallStart) 218 219 var destinations []destinationFromService 220 if err := json.NewDecoder(res.Body).Decode(&destinations); err != nil { 221 return nil, errors.Wrap(err, "failed to decode response body") 222 } 223 224 destinationsPageCallFullDuration := time.Since(destinationsPageCallStart) 225 226 pageCountHeader := res.Header.Get(c.apiConfig.PagingCountHeader) 227 if pageCountHeader == "" { 228 return nil, errors.Errorf("missing '%s' header from destinations response", c.apiConfig.PagingCountHeader) 229 } 230 pageCount, err := strconv.Atoi(pageCountHeader) 231 if err != nil { 232 return nil, errors.Errorf("invalid header '%s' '%s'", c.apiConfig.PagingCountHeader, pageCountHeader) 233 } 234 235 logDuration := log.C(ctx).Debugf 236 if destinationsPageCallFullDuration > c.apiConfig.Timeout/2 { 237 logDuration = log.C(ctx).Warnf 238 } 239 destinationCorrelationHeader := c.apiConfig.ResponseCorrelationIDHeader 240 logDuration("Getting tenant '%s' destinations page %s/%s took %s, %s of which for headers, %s: '%s'", 241 tenantID, page, pageCountHeader, destinationsPageCallFullDuration.String(), destinationsPageCallHeadersDuration.String(), 242 destinationCorrelationHeader, res.Header.Get(destinationCorrelationHeader)) 243 244 return &DestinationResponse{ 245 destinations: destinations, 246 pageCount: pageCount, 247 }, nil 248 } 249 250 func (c *Client) setToken(req *http.Request) error { 251 token, err := c.getToken(req.Context()) 252 if err != nil { 253 return err 254 } 255 256 req.Header.Set(authorizationHeader, "Bearer "+token) 257 return nil 258 } 259 260 func (c *Client) buildFetchRequest(ctx context.Context, url string, page string) (*http.Request, error) { 261 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 262 if err != nil { 263 return nil, errors.Wrapf(err, "failed to build request") 264 } 265 headers := correlation.HeadersForRequest(req) 266 for key, value := range headers { 267 req.Header.Set(key, value) 268 } 269 query := req.URL.Query() 270 query.Add(c.apiConfig.PagingCountParam, "true") 271 query.Add(c.apiConfig.PagingPageParam, page) 272 query.Add(c.apiConfig.PagingSizeParam, strconv.Itoa(c.apiConfig.PageSize)) 273 req.URL.RawQuery = query.Encode() 274 if err := c.setToken(req); err != nil { 275 return nil, err 276 } 277 return req, nil 278 } 279 280 type token struct { 281 AccessToken string `json:"access_token,omitempty"` 282 ExpiresInSeconds int64 `json:"expires_in,omitempty"` 283 } 284 285 func (c *Client) getToken(ctx context.Context) (string, error) { 286 if c.authToken != "" && time.Now().Before(c.authTokenValidity.Add(-c.apiConfig.Timeout)) { 287 return c.authToken, nil 288 } 289 tokenRequestBody := strings.NewReader(url.Values{ 290 "grant_type": {"client_credentials"}, 291 "client_id": {c.authConfig.ClientID}, 292 "client_secret": {c.authConfig.ClientSecret}, 293 }.Encode()) 294 tokenRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authConfig.TokenURL, tokenRequestBody) 295 if err != nil { 296 return "", errors.Wrap(err, "failed to create token request") 297 } 298 tokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") 299 300 authToken, err := c.doTokenRequest(tokenRequest) 301 if err != nil { 302 return "", errors.Wrap(err, "failed to do token request") 303 } 304 c.authToken = authToken.AccessToken 305 c.authTokenValidity = time.Now().Add(time.Second * time.Duration(authToken.ExpiresInSeconds)) 306 return c.authToken, nil 307 } 308 309 func (c *Client) doTokenRequest(tokenRequest *http.Request) (token, error) { 310 res, err := c.HTTPClient.Do(tokenRequest) 311 if err != nil { 312 return token{}, err 313 } 314 defer func() { 315 if err := res.Body.Close(); err != nil { 316 log.C(tokenRequest.Context()).WithError(err).Error("Unable to close token response body") 317 } 318 }() 319 if res.StatusCode != http.StatusOK { 320 return token{}, fmt.Errorf("token request failed with %s", res.Status) 321 } 322 authToken := token{} 323 if err := json.NewDecoder(res.Body).Decode(&authToken); err != nil { 324 return token{}, errors.Wrap(err, "failed to decode token") 325 } 326 return authToken, nil 327 } 328 329 // FetchDestinationSensitiveData returns sensitive data of a destination 330 func (c *Client) FetchDestinationSensitiveData(ctx context.Context, destinationName string) ([]byte, error) { 331 fetchURL := fmt.Sprintf("%s%s/%s", c.authConfig.URL, c.apiConfig.EndpointFindDestination, destinationName) 332 req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) 333 if err != nil { 334 return nil, errors.Wrapf(err, "failed to build request") 335 } 336 req.Header.Set(correlation.RequestIDHeaderKey, correlation.CorrelationIDForRequest(req)) 337 if err := c.setToken(req); err != nil { 338 return nil, err 339 } 340 res, err := c.sendRequestWithRetry(req) 341 if err != nil { 342 return nil, err 343 } 344 defer func() { 345 if err := res.Body.Close(); err != nil { 346 log.C(req.Context()).WithError(err).Error("Unable to close response body") 347 } 348 }() 349 350 if res.StatusCode == http.StatusNotFound { 351 return nil, apperrors.NewNotFoundError(resource.Destination, destinationName) 352 } 353 354 if res.StatusCode != http.StatusOK { 355 return nil, errors.Errorf("received status code %d when trying to get destination info for %s", 356 res.StatusCode, destinationName) 357 } 358 359 body, err := io.ReadAll(res.Body) 360 361 if err != nil { 362 return nil, errors.Wrap(err, "failed to read response body") 363 } 364 365 return body, nil 366 } 367 368 func (c *Client) sendRequestWithRetry(req *http.Request) (*http.Response, error) { 369 var response *http.Response 370 err := retry.Do(func() error { 371 res, err := c.HTTPClient.Do(req) 372 if err != nil { 373 return errors.Wrap(err, "failed to execute HTTP request") 374 } 375 376 if err == nil && res.StatusCode < http.StatusInternalServerError { 377 response = res 378 return nil 379 } 380 381 defer func() { 382 if err := res.Body.Close(); err != nil { 383 log.C(req.Context()).WithError(err).Error("Unable to close response body") 384 } 385 }() 386 body, err := io.ReadAll(res.Body) 387 388 if err != nil { 389 return errors.Wrap(err, "failed to read response body") 390 } 391 return errors.Errorf("request failed with status code %d, error message: %v", res.StatusCode, string(body)) 392 }, retry.Attempts(c.apiConfig.RetryAttempts), retry.Delay(c.apiConfig.RetryInterval)) 393 394 return response, err 395 }