internal/cloud/endpoints.go (172 lines of code) (raw):
/*
* Copyright 2021-2024 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package cloud
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"time"
"github.com/JetBrains/qodana-cli/internal/platform/qdenv"
log "github.com/sirupsen/logrus"
)
const (
DefaultEndpoint = "https://qodana.cloud"
defaultNumberOfRetries = 3
defaultCooldownTimeSeconds = 30
defaultRequestTimeout = 30
)
// QdRootEndpoint contains scheme, hostname and port
type QdRootEndpoint struct {
Url string
}
type QdApiEndpoints struct {
RootEndpoint *QdRootEndpoint
LintersApiUrl string
CloudApiUrl string
}
type QdClient struct {
apiUrl string
httpClient *http.Client
token string
}
var endpoint *QdRootEndpoint
var endpointApis *QdApiEndpoints
func GetCloudApiEndpoints() *QdApiEndpoints {
if endpointApis == nil {
apis, err := GetCloudRootEndpoint().requestApiEndpoints()
if err != nil {
log.Fatalf("Failed to obtain proper API endpoints: %v", err)
}
endpointApis = apis
}
return endpointApis
}
func GetCloudRootEndpoint() *QdRootEndpoint {
if endpoint != nil {
return endpoint
}
userUrl := qdenv.GetQodanaGlobalEnv(qdenv.QodanaEndpointEnv)
if userUrl == "" {
userUrl = DefaultEndpoint
}
host, err := parseRawURL(userUrl)
if err != nil {
log.Fatal(err)
}
endpoint = &QdRootEndpoint{host}
return endpoint
}
func parseRawURL(rawUrl string) (host string, err error) {
parsedUrl, err := url.ParseRequestURI(rawUrl)
if err != nil || parsedUrl.Host == "" {
parsedUrl, repErr := url.ParseRequestURI("https://" + rawUrl)
if repErr != nil {
return "", err
}
return fmt.Sprintf("%s://%s", parsedUrl.Scheme, parsedUrl.Host), nil
}
return fmt.Sprintf("%s://%s", parsedUrl.Scheme, parsedUrl.Host), nil
}
func (endpoints *QdApiEndpoints) NewCloudApiClient(token string) *QdClient {
return &QdClient{
httpClient: &http.Client{
Timeout: getRequestTimeout(),
},
apiUrl: endpoints.CloudApiUrl,
token: token,
}
}
func getRequestTimeout() time.Duration {
return time.Duration(GetEnvWithDefaultInt(qdenv.QodanaCloudRequestTimeoutEnv, defaultRequestTimeout)) * time.Second
}
func (endpoints *QdApiEndpoints) NewLintersApiClient(token string) *QdClient {
return &QdClient{
httpClient: &http.Client{
Timeout: getRequestTimeout(),
},
apiUrl: endpoints.LintersApiUrl,
token: token,
}
}
type APIError struct {
StatusCode int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("response code '%d', message '%s'", e.StatusCode, e.Message)
}
type QdCloudRequest struct {
Path string
Method string
Headers map[string]string
Body []byte
AcceptedStatuses []int
Retries int
Cooldown int
}
func NewCloudRequest(path string) QdCloudRequest {
return QdCloudRequest{
Path: path,
Method: "GET",
AcceptedStatuses: []int{http.StatusUnauthorized, http.StatusNotFound},
Retries: GetEnvWithDefaultInt(qdenv.QodanaCloudRequestRetriesEnv, defaultNumberOfRetries),
Cooldown: GetEnvWithDefaultInt(qdenv.QodanaCloudRequestCooldownEnv, defaultCooldownTimeSeconds),
}
}
func (client *QdClient) doRequest(request *QdCloudRequest) ([]byte, error) {
var response []byte
var err error
for i := 1; i <= request.Retries; i++ {
response, err = client.doRequestAttempt(request)
if err == nil {
return response, nil
}
var versionError *APIError
if errors.As(err, &versionError) {
if slices.Contains(request.AcceptedStatuses, versionError.StatusCode) {
return nil, err // return if accepted status code, like 401
}
}
log.Errorf("Attempt #%d of %d for request to '%s' failed. Error: %v", i, request.Retries, request.Path, err)
if i < request.Retries {
log.Printf("Next attempt in %d seconds", request.Cooldown)
time.Sleep(time.Duration(request.Cooldown) * time.Second)
}
}
return response, errors.New("failed to obtain proper cloud response")
}
func (client *QdClient) doRequestAttempt(request *QdCloudRequest) ([]byte, error) {
requestUrl := client.apiUrl + request.Path
var resp *http.Response
var responseErr error
req, err := http.NewRequest(request.Method, requestUrl, bytes.NewBuffer(request.Body))
if err != nil {
return nil, err
}
if client.token != "" {
req.Header.Set("Authorization", "Bearer "+client.token)
}
req.Header.Set("Content-Type", "application/json")
for key, value := range request.Headers {
req.Header.Set(key, value)
}
resp, responseErr = client.httpClient.Do(req)
if responseErr != nil {
return nil, responseErr
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
log.Fatal(err)
}
}(resp.Body)
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return responseBody, nil
}
return nil, &APIError{StatusCode: resp.StatusCode, Message: string(responseBody)}
}