195 lines
7.6 KiB
Go
195 lines
7.6 KiB
Go
package clients
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
volcesAssetDefaultEndpoint = "https://ark.cn-beijing.volcengineapi.com"
|
|
volcesAssetRegion = "cn-beijing"
|
|
volcesAssetService = "ark"
|
|
volcesAssetVersion = "2024-01-01"
|
|
)
|
|
|
|
type VolcesAssetClient struct {
|
|
HTTPClient *http.Client
|
|
Now func() time.Time
|
|
}
|
|
|
|
type VolcesAssetCredentials struct {
|
|
AccessKey string
|
|
SecretKey string
|
|
Endpoint string
|
|
}
|
|
|
|
type VolcesAssetResult struct {
|
|
ID string `json:"Id"`
|
|
Name string `json:"Name,omitempty"`
|
|
URL string `json:"URL,omitempty"`
|
|
AssetType string `json:"AssetType,omitempty"`
|
|
GroupID string `json:"GroupId,omitempty"`
|
|
Status string `json:"Status,omitempty"`
|
|
Error map[string]any `json:"Error,omitempty"`
|
|
ProjectName string `json:"ProjectName,omitempty"`
|
|
CreateTime string `json:"CreateTime,omitempty"`
|
|
UpdateTime string `json:"UpdateTime,omitempty"`
|
|
}
|
|
|
|
func (c VolcesAssetClient) CreateAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) {
|
|
var result struct {
|
|
ID string `json:"Id"`
|
|
}
|
|
requestID, err := c.call(ctx, credentials, "CreateAsset", body, &result)
|
|
return VolcesAssetResult{ID: result.ID}, requestID, err
|
|
}
|
|
|
|
func (c VolcesAssetClient) GetAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) {
|
|
var result VolcesAssetResult
|
|
requestID, err := c.call(ctx, credentials, "GetAsset", body, &result)
|
|
return result, requestID, err
|
|
}
|
|
|
|
func (c VolcesAssetClient) call(ctx context.Context, credentials VolcesAssetCredentials, action string, body map[string]any, target any) (string, error) {
|
|
accessKey := strings.TrimSpace(credentials.AccessKey)
|
|
secretKey := strings.TrimSpace(credentials.SecretKey)
|
|
if accessKey == "" || secretKey == "" {
|
|
return "", &ClientError{Code: "missing_credentials", Message: "volces portrait asset accessKey and secretKey are required", Retryable: false}
|
|
}
|
|
endpoint := strings.TrimRight(strings.TrimSpace(credentials.Endpoint), "/")
|
|
if endpoint == "" {
|
|
endpoint = volcesAssetDefaultEndpoint
|
|
}
|
|
baseURL, err := url.Parse(endpoint)
|
|
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
|
return "", &ClientError{Code: "invalid_configuration", Message: "invalid volces portrait asset endpoint", Retryable: false}
|
|
}
|
|
bodyJSON, err := json.Marshal(body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal volces asset request: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
if c.Now != nil {
|
|
now = c.Now().UTC()
|
|
}
|
|
xDate := now.Format("20060102T150405Z")
|
|
contentSHA := sha256HexBytes(bodyJSON)
|
|
requestURL := *baseURL
|
|
requestURL.Path = "/"
|
|
requestURL.RawPath = ""
|
|
requestURL.RawQuery = canonicalVolcesAssetQuery(map[string]string{"Action": action, "Version": volcesAssetVersion})
|
|
headers := map[string]string{
|
|
"content-type": "application/json",
|
|
"host": baseURL.Host,
|
|
"x-content-sha256": contentSHA,
|
|
"x-date": xDate,
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(bodyJSON))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Host = baseURL.Host
|
|
req.Header.Set("Content-Type", headers["content-type"])
|
|
req.Header.Set("X-Content-Sha256", headers["x-content-sha256"])
|
|
req.Header.Set("X-Date", headers["x-date"])
|
|
req.Header.Set("Authorization", volcesAssetAuthorization(accessKey, secretKey, http.MethodPost, "/", requestURL.RawQuery, headers, contentSHA, xDate))
|
|
|
|
response, err := httpClient(nil, c.HTTPClient).Do(req)
|
|
if err != nil {
|
|
return "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
|
}
|
|
defer response.Body.Close()
|
|
var envelope struct {
|
|
ResponseMetadata struct {
|
|
RequestID string `json:"RequestId"`
|
|
Error struct {
|
|
Code string `json:"Code"`
|
|
Message string `json:"Message"`
|
|
} `json:"Error"`
|
|
} `json:"ResponseMetadata"`
|
|
Result json.RawMessage `json:"Result"`
|
|
}
|
|
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
|
|
return requestIDFromHTTPResponse(response), &ClientError{Code: "invalid_response", Message: "decode volces portrait asset response: " + err.Error(), Retryable: HTTPRetryable(response.StatusCode), StatusCode: response.StatusCode}
|
|
}
|
|
requestID := firstNonEmpty(requestIDFromHTTPResponse(response), envelope.ResponseMetadata.RequestID)
|
|
if envelope.ResponseMetadata.Error.Code != "" || response.StatusCode >= http.StatusBadRequest {
|
|
message := strings.TrimSpace(envelope.ResponseMetadata.Error.Message)
|
|
if message == "" {
|
|
message = strings.TrimSpace(envelope.ResponseMetadata.Error.Code)
|
|
}
|
|
if message == "" {
|
|
message = fmt.Sprintf("volces %s failed with status %d", action, response.StatusCode)
|
|
}
|
|
return requestID, &ClientError{Code: firstNonEmpty(envelope.ResponseMetadata.Error.Code, "volces_asset_error"), Message: message, RequestID: requestID, StatusCode: response.StatusCode, Retryable: HTTPRetryable(response.StatusCode)}
|
|
}
|
|
if len(envelope.Result) == 0 || string(envelope.Result) == "null" {
|
|
return requestID, &ClientError{Code: "invalid_response", Message: "volces " + action + " returned empty result", RequestID: requestID, Retryable: false}
|
|
}
|
|
if err := json.Unmarshal(envelope.Result, target); err != nil {
|
|
return requestID, &ClientError{Code: "invalid_response", Message: "decode volces " + action + " result: " + err.Error(), RequestID: requestID, Retryable: false}
|
|
}
|
|
return requestID, nil
|
|
}
|
|
|
|
func volcesAssetAuthorization(accessKey string, secretKey string, method string, path string, canonicalQuery string, headers map[string]string, bodySHA string, xDate string) string {
|
|
signedHeaders := []string{"content-type", "host", "x-content-sha256", "x-date"}
|
|
canonicalHeaderLines := make([]string, 0, len(signedHeaders))
|
|
for _, key := range signedHeaders {
|
|
canonicalHeaderLines = append(canonicalHeaderLines, key+":"+strings.TrimSpace(headers[key]))
|
|
}
|
|
canonicalRequest := strings.Join([]string{
|
|
strings.ToUpper(method), path, canonicalQuery,
|
|
strings.Join(canonicalHeaderLines, "\n") + "\n",
|
|
strings.Join(signedHeaders, ";"), bodySHA,
|
|
}, "\n")
|
|
date := xDate
|
|
if len(date) >= 8 {
|
|
date = date[:8]
|
|
}
|
|
scope := strings.Join([]string{date, volcesAssetRegion, volcesAssetService, "request"}, "/")
|
|
stringToSign := strings.Join([]string{"HMAC-SHA256", xDate, scope, sha256HexString(canonicalRequest)}, "\n")
|
|
kDate := hmacSHA256([]byte(secretKey), date)
|
|
kRegion := hmacSHA256(kDate, volcesAssetRegion)
|
|
kService := hmacSHA256(kRegion, volcesAssetService)
|
|
kSigning := hmacSHA256(kService, "request")
|
|
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
|
return "HMAC-SHA256 Credential=" + accessKey + "/" + scope + ", SignedHeaders=" + strings.Join(signedHeaders, ";") + ", Signature=" + signature
|
|
}
|
|
|
|
func canonicalVolcesAssetQuery(values map[string]string) string {
|
|
keys := make([]string, 0, len(values))
|
|
for key := range values {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(values[key]))
|
|
}
|
|
return strings.ReplaceAll(strings.Join(parts, "&"), "+", "%20")
|
|
}
|
|
|
|
func sha256HexBytes(value []byte) string {
|
|
digest := sha256.Sum256(value)
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func sha256HexString(value string) string { return sha256HexBytes([]byte(value)) }
|
|
|
|
func hmacSHA256(key []byte, value string) []byte {
|
|
mac := hmac.New(sha256.New, key)
|
|
_, _ = mac.Write([]byte(value))
|
|
return mac.Sum(nil)
|
|
}
|