@@ -0,0 +1,238 @@
|
||||
package awsserversdk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Version = "0.1.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, "/")
|
||||
}
|
||||
Reference in New Issue
Block a user