package awsserversdk import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const Version = "0.5.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<= 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, "/") }