239 lines
6.9 KiB
Go
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, "/")
|
|
}
|