1
0
mirror of https://github.com/taigrr/wtf synced 2025-01-18 04:03:14 -08:00
2020-01-17 20:42:25 +00:00

418 lines
12 KiB
Go

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package azuredevops
import (
"bytes"
"context"
"encoding/json"
"github.com/google/uuid"
"io"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"runtime"
"strings"
"sync"
)
const (
// header keys
headerKeyAccept = "Accept"
headerKeyAuthorization = "Authorization"
headerKeyContentType = "Content-Type"
HeaderKeyContinuationToken = "X-MS-ContinuationToken"
headerKeyFedAuthRedirect = "X-TFS-FedAuthRedirect"
headerKeyForceMsaPassThrough = "X-VSS-ForceMsaPassThrough"
headerKeySession = "X-TFS-Session"
headerUserAgent = "User-Agent"
// media types
MediaTypeTextPlain = "text/plain"
MediaTypeApplicationJson = "application/json"
)
// Unique session id to be used by all requests of this session.
var SessionId = uuid.New().String()
// ApiResourceLocation Cache by Url
var apiResourceLocationCache = make(map[string]*map[uuid.UUID]ApiResourceLocation)
var apiResourceLocationCacheLock = sync.RWMutex{}
// Base user agent string. The UserAgent set on the connection will be appended to this.
var baseUserAgent = "go/" + runtime.Version() + " (" + runtime.GOOS + " " + runtime.GOARCH + ") azure-devops-go-api/0.0.0" // todo: get real version
func NewClient(connection *Connection, baseUrl string) *Client {
client := &http.Client{}
if connection.Timeout != nil {
client.Timeout = *connection.Timeout
}
return &Client{
baseUrl: baseUrl,
client: client,
authorization: connection.AuthorizationString,
suppressFedAuthRedirect: connection.SuppressFedAuthRedirect,
forceMsaPassThrough: connection.ForceMsaPassThrough,
userAgent: connection.UserAgent,
}
}
type Client struct {
baseUrl string
client *http.Client
authorization string
suppressFedAuthRedirect bool
forceMsaPassThrough bool
userAgent string
}
func (client *Client) SendRequest(request *http.Request) (response *http.Response, err error) {
resp, err := client.client.Do(request) // todo: add retry logic
if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
err = client.UnwrapError(resp)
}
return resp, err
}
func (client *Client) Send(ctx context.Context,
httpMethod string,
locationId uuid.UUID,
apiVersion string,
routeValues map[string]string,
queryParameters url.Values,
body io.Reader,
mediaType string,
acceptMediaType string,
additionalHeaders map[string]string) (response *http.Response, err error) {
location, err := client.getResourceLocation(ctx, locationId)
if err != nil {
return nil, err
}
generatedUrl := client.GenerateUrl(location, routeValues, queryParameters)
fullUrl := combineUrl(client.baseUrl, generatedUrl)
negotiatedVersion, err := negotiateRequestVersion(location, apiVersion)
if err != nil {
return nil, err
}
req, err := client.CreateRequestMessage(ctx, httpMethod, fullUrl, negotiatedVersion, body, mediaType, acceptMediaType, additionalHeaders)
if err != nil {
return nil, err
}
resp, err := client.SendRequest(req)
if err != nil {
return nil, err
}
// Set session if one was supplied in the response.
session, ok := resp.Header[headerKeySession]
if ok && len(session) > 0 {
SessionId = session[0]
}
return resp, err
}
func (client *Client) GenerateUrl(apiResourceLocation *ApiResourceLocation, routeValues map[string]string, queryParameters url.Values) (request string) {
builtUrl := *apiResourceLocation.RouteTemplate
if routeValues == nil {
routeValues = make(map[string]string)
}
routeValues["area"] = *apiResourceLocation.Area
routeValues["resource"] = *apiResourceLocation.ResourceName
builtUrl = transformRouteTemplate(builtUrl, routeValues)
if queryParameters != nil && len(queryParameters) > 0 {
builtUrl += "?" + queryParameters.Encode()
}
return builtUrl
}
func (client *Client) CreateRequestMessage(ctx context.Context,
httpMethod string,
url string,
apiVersion string,
body io.Reader,
mediaType string,
acceptMediaType string,
additionalHeaders map[string]string) (request *http.Request, err error) {
req, err := http.NewRequest(httpMethod, url, body)
if err != nil {
return nil, err
}
if ctx != nil {
req = req.WithContext(ctx)
}
if client.authorization != "" {
req.Header.Add(headerKeyAuthorization, client.authorization)
}
accept := acceptMediaType
if apiVersion != "" {
accept += ";api-version=" + apiVersion
}
req.Header.Add(headerKeyAccept, accept)
if mediaType != "" {
req.Header.Add(headerKeyContentType, mediaType+";charset=utf-8")
}
if client.suppressFedAuthRedirect {
req.Header.Add(headerKeyFedAuthRedirect, "Suppress")
}
if client.forceMsaPassThrough {
req.Header.Add(headerKeyForceMsaPassThrough, "true")
}
// set session if it has not already been set
_, ok := req.Header[headerKeySession]
if !ok {
req.Header.Add(headerKeySession, SessionId)
}
userAgent := baseUserAgent
if client.userAgent != "" {
userAgent += " " + client.userAgent
}
req.Header.Add(headerUserAgent, userAgent)
for key, value := range additionalHeaders {
req.Header.Add(key, value)
}
return req, err
}
func (client *Client) getResourceLocation(ctx context.Context, locationId uuid.UUID) (*ApiResourceLocation, error) {
locationsMap, ok := getApiResourceLocationCache(client.baseUrl)
if !ok {
locations, err := client.getResourceLocationsFromServer(ctx)
if err != nil {
return nil, err
}
newMap := make(map[uuid.UUID]ApiResourceLocation)
locationsMap = &newMap
for _, locationEntry := range locations {
(*locationsMap)[*locationEntry.Id] = locationEntry
}
setApiResourceLocationCache(client.baseUrl, locationsMap)
}
location, ok := (*locationsMap)[locationId]
if ok {
return &location, nil
}
return nil, &LocationIdNotRegisteredError{locationId, client.baseUrl}
}
func getApiResourceLocationCache(url string) (*map[uuid.UUID]ApiResourceLocation, bool) {
apiResourceLocationCacheLock.RLock()
defer apiResourceLocationCacheLock.RUnlock()
locationsMap, ok := apiResourceLocationCache[url]
return locationsMap, ok
}
func setApiResourceLocationCache(url string, locationsMap *map[uuid.UUID]ApiResourceLocation) {
apiResourceLocationCacheLock.Lock()
defer apiResourceLocationCacheLock.Unlock()
apiResourceLocationCache[url] = locationsMap
}
func (client *Client) getResourceLocationsFromServer(ctx context.Context) ([]ApiResourceLocation, error) {
optionsUri := combineUrl(client.baseUrl, "_apis")
request, err := client.CreateRequestMessage(ctx, http.MethodOptions, optionsUri, "", nil, "", MediaTypeApplicationJson, nil)
if err != nil {
return nil, err
}
resp, err := client.SendRequest(request)
if err != nil {
return nil, err
}
// Set session if one was supplied in the response.
session, ok := resp.Header[headerKeySession]
if ok && len(session) > 0 {
SessionId = session[0]
}
if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
return nil, client.UnwrapError(resp)
}
var locations []ApiResourceLocation
err = client.UnmarshalCollectionBody(resp, &locations)
return locations, err
}
// Examples of api-version: 5.1, 5.1-preview, 5.1-preview.1
var apiVersionRegEx = regexp.MustCompile(`(\d+(\.\d)?)(-preview(.(\d+))?)?`)
func combineUrl(part1 string, part2 string) string {
return strings.TrimRight(part1, "/") + "/" + strings.TrimLeft(part2, "/")
}
func transformRouteTemplate(routeTemplate string, routeValues map[string]string) string {
newTemplate := ""
routeTemplate = strings.Replace(routeTemplate, "{*", "{", -1)
segments := strings.Split(routeTemplate, "/")
for _, segment := range segments {
length := len(segment)
if length <= 2 || segment[0] != '{' || segment[length-1] != '}' {
newTemplate += "/" + segment
} else {
value, ok := routeValues[segment[1:length-1]]
if ok {
newTemplate += "/" + url.PathEscape(value)
}
// else this is an optional parameter that has not been supplied, so don't add it back
}
}
// following covers oddball templates with segments that include the token and additional constants
for key, value := range routeValues {
newTemplate = strings.Replace(newTemplate, "{"+key+"}", value, -1)
}
return newTemplate
}
func (client *Client) UnmarshalBody(response *http.Response, v interface{}) (err error) {
if response != nil && response.Body != nil {
var err error
defer func() {
if closeError := response.Body.Close(); closeError != nil {
err = closeError
}
}()
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
body = trimByteOrderMark(body)
return json.Unmarshal(body, &v)
}
return nil
}
func (client *Client) UnmarshalCollectionBody(response *http.Response, v interface{}) (err error) {
if response != nil && response.Body != nil {
var err error
defer func() {
if closeError := response.Body.Close(); closeError != nil {
err = closeError
}
}()
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
body = trimByteOrderMark(body)
err = client.UnmarshalCollectionJson(body, v)
}
return nil
}
func (client *Client) UnmarshalCollectionJson(jsonValue []byte, v interface{}) (err error) {
var wrappedResponse VssJsonCollectionWrapper
err = json.Unmarshal(jsonValue, &wrappedResponse)
if err != nil {
return err
}
value, err := json.Marshal(wrappedResponse.Value) // todo: investigate better way to do this.
if err != nil {
return err
}
return json.Unmarshal(value, &v)
}
// Returns slice of body without utf-8 byte order mark.
// If BOM does not exist body is returned unchanged.
func trimByteOrderMark(body []byte) []byte {
return bytes.TrimPrefix(body, []byte("\xef\xbb\xbf"))
}
func (client *Client) UnwrapError(response *http.Response) (err error) {
if response.ContentLength == 0 {
message := "Request returned status: " + response.Status
return &WrappedError{
Message: &message,
StatusCode: &response.StatusCode,
}
}
defer func() {
if closeError := response.Body.Close(); closeError != nil {
err = closeError
}
}()
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
body = trimByteOrderMark(body)
contentType, ok := response.Header[headerKeyContentType]
if ok && len(contentType) > 0 && strings.Index(contentType[0], MediaTypeTextPlain) >= 0 {
message := string(body)
statusCode := response.StatusCode
return WrappedError{Message: &message, StatusCode: &statusCode}
}
var wrappedError WrappedError
err = json.Unmarshal(body, &wrappedError)
if err != nil {
return err
}
if wrappedError.Message == nil {
var wrappedImproperError WrappedImproperError
err = json.Unmarshal(body, &wrappedImproperError)
if err == nil && wrappedImproperError.Value != nil && wrappedImproperError.Value.Message != nil {
statusCode := response.StatusCode
return &WrappedError{
Message: wrappedImproperError.Value.Message,
StatusCode: &statusCode,
}
}
}
return wrappedError
}
func (client *Client) GetResourceAreas(ctx context.Context) (*[]ResourceAreaInfo, error) {
queryParams := url.Values{}
locationId, _ := uuid.Parse("e81700f7-3be2-46de-8624-2eb35882fcaa")
resp, err := client.Send(ctx, http.MethodGet, locationId, "5.1-preview.1", nil, queryParams, nil, "", "application/json", nil)
if err != nil {
return nil, err
}
var responseValue []ResourceAreaInfo
err = client.UnmarshalCollectionBody(resp, &responseValue)
return &responseValue, err
}
type LocationIdNotRegisteredError struct {
LocationId uuid.UUID
Url string
}
func (e LocationIdNotRegisteredError) Error() string {
return "API resource location " + e.LocationId.String() + " is not registered on " + e.Url + "."
}
type InvalidApiVersion struct {
ApiVersion string
}
func (e InvalidApiVersion) Error() string {
return "The requested api-version is not in a valid format: " + e.ApiVersion
}