Files
2026-09-02 14:47:38 +08:00

239 lines
6.9 KiB
Go

package awsserversdk
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const Version = "0.6.0"
type Logger interface {
Printf(format string, args ...any)
}
type Option func(*Client)
func WithHTTPClient(value *http.Client) Option {
return func(c *Client) {
if value != nil {
c.httpClient = value
}
}
}
func WithUserAgent(value string) Option {
return func(c *Client) {
if strings.TrimSpace(value) != "" {
c.userAgent = value
}
}
}
func WithMaxRetries(value int) Option {
return func(c *Client) {
if value >= 0 {
c.maxRetries = value
}
}
}
func WithLogger(value Logger) Option { return func(c *Client) { c.logger = value } }
type Client struct {
baseURL *url.URL
token string
httpClient *http.Client
userAgent string
maxRetries int
logger Logger
Catalog *CatalogService
Pricing *PricingService
Instances *InstancesService
SecurityGroups *SecurityGroupsService
EIPs *EIPService
Volumes *VolumeService
Traffic *TrafficService
Access *AccessService
Agent *AgentService
Tasks *TaskService
}
func NewClient(baseURL, applicationToken string, options ...Option) (*Client, error) {
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/"))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("awsserversdk: invalid base URL")
}
if !strings.HasPrefix(applicationToken, "awsapp_") {
return nil, errors.New("awsserversdk: invalid application token")
}
c := &Client{baseURL: parsed, token: applicationToken, httpClient: &http.Client{Timeout: 30 * time.Second}, userAgent: "aws-server-sdk-go/" + Version, maxRetries: 3}
for _, option := range options {
option(c)
}
c.Catalog = &CatalogService{client: c}
c.Pricing = &PricingService{client: c}
c.Instances = &InstancesService{client: c}
c.SecurityGroups = &SecurityGroupsService{client: c}
c.EIPs = &EIPService{client: c}
c.Volumes = &VolumeService{client: c}
c.Traffic = &TrafficService{client: c}
c.Access = &AccessService{client: c}
c.Agent = &AgentService{client: c}
c.Tasks = &TaskService{client: c}
return c, nil
}
type RequestOption func(*requestOptions)
type requestOptions struct{ idempotencyKey string }
func WithIdempotencyKey(value string) RequestOption {
return func(v *requestOptions) { v.idempotencyKey = strings.TrimSpace(value) }
}
func mutationOptions(options []RequestOption) requestOptions {
v := requestOptions{}
for _, option := range options {
option(&v)
}
if v.idempotencyKey == "" {
v.idempotencyKey = newID()
}
return v
}
type envelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func (c *Client) do(ctx context.Context, method, path string, input, output any, idempotencyKey string) error {
var payload []byte
var err error
if input != nil {
payload, err = json.Marshal(input)
if err != nil {
return fmt.Errorf("awsserversdk: encode request: %w", err)
}
}
endpoint := *c.baseURL
relative, parseErr := url.Parse("/api/sdk/v1" + path)
if parseErr != nil {
return fmt.Errorf("awsserversdk: invalid request path: %w", parseErr)
}
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + relative.Path
endpoint.RawQuery = relative.RawQuery
for attempt := 0; ; attempt++ {
var body io.Reader
if payload != nil {
body = bytes.NewReader(payload)
}
req, requestErr := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
if requestErr != nil {
return requestErr
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", c.userAgent)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
response, requestErr := c.httpClient.Do(req)
if requestErr != nil {
if attempt < c.maxRetries && retryableMethod(method, idempotencyKey) {
if waitErr := sleepContext(ctx, retryDelay(attempt)); waitErr != nil {
return waitErr
}
continue
}
return requestErr
}
raw, readErr := io.ReadAll(io.LimitReader(response.Body, 8<<20))
response.Body.Close()
if readErr != nil {
return readErr
}
if retryableStatus(response.StatusCode) && attempt < c.maxRetries && retryableMethod(method, idempotencyKey) {
delay := retryAfter(response.Header.Get("Retry-After"), attempt)
if c.logger != nil {
c.logger.Printf("awsserversdk: retrying %s %s after HTTP %d", method, path, response.StatusCode)
}
if waitErr := sleepContext(ctx, delay); waitErr != nil {
return waitErr
}
continue
}
var wrapped envelope
if err := json.Unmarshal(raw, &wrapped); err != nil {
return &APIError{StatusCode: response.StatusCode, Code: response.StatusCode, Message: "invalid API response", Body: string(raw)}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return &APIError{StatusCode: response.StatusCode, Code: wrapped.Code, Message: wrapped.Message, Data: wrapped.Data}
}
if output != nil && len(wrapped.Data) > 0 && string(wrapped.Data) != "null" {
if err := json.Unmarshal(wrapped.Data, output); err != nil {
return fmt.Errorf("awsserversdk: decode response: %w", err)
}
}
return nil
}
}
func retryableMethod(method, key string) bool {
return method == http.MethodGet || method == http.MethodHead || key != ""
}
func retryableStatus(code int) bool {
return code == http.StatusTooManyRequests || code == http.StatusBadGateway || code == http.StatusServiceUnavailable || code == http.StatusGatewayTimeout
}
func retryDelay(attempt int) time.Duration {
delay := 200 * time.Millisecond * time.Duration(1<<min(attempt, 5))
var jitter [1]byte
_, _ = rand.Read(jitter[:])
return delay + time.Duration(jitter[0])*time.Millisecond
}
func retryAfter(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(value); err == nil && at.After(time.Now()) {
return time.Until(at)
}
return retryDelay(attempt)
}
func sleepContext(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func newID() string {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return fmt.Sprintf("request-%d", time.Now().UnixNano())
}
value[6] = (value[6] & 0x0f) | 0x40
value[8] = (value[8] & 0x3f) | 0x80
raw := hex.EncodeToString(value[:])
return raw[:8] + "-" + raw[8:12] + "-" + raw[12:16] + "-" + raw[16:20] + "-" + raw[20:]
}
func escaped(values ...string) string {
parts := make([]string, len(values))
for i, value := range values {
parts[i] = url.PathEscape(value)
}
return strings.Join(parts, "/")
}