feat(sdk): ✨ Implement EdgeXR Master Controller Go SDK foundation
Phase 1 Implementation - Core SDK foundation with typed APIs: ## New Components Added: - **SDK Package Structure**: `/sdk/client`, `/sdk/internal/http`, `/sdk/examples` - **Core Types**: App, AppInstance, Cloudlet with JSON marshaling - **HTTP Transport**: Resilient HTTP client with go-retryablehttp - **Auth System**: Pluggable providers (StaticToken, NoAuth) - **Client**: Configurable SDK client with retry and logging options ## API Implementation: - **App Management**: CreateApp, ShowApp, ShowApps, DeleteApp - **Error Handling**: Structured APIError with status codes and messages - **Response Parsing**: EdgeXR streaming JSON response support - **Context Support**: All APIs accept context.Context for timeouts/cancellation ## Testing & Examples: - **Unit Tests**: Comprehensive test suite with httptest mock servers - **Example App**: Complete app lifecycle demonstration in examples/deploy_app.go - **Test Coverage**: Create, show, list, delete operations with error conditions ## Build Infrastructure: - **Makefile**: Automated code generation, testing, and building - **Dependencies**: Added go-retryablehttp, testify, oapi-codegen - **Configuration**: oapi-codegen.yaml for type generation ## API Mapping: - CreateApp → POST /auth/ctrl/CreateApp - ShowApp → POST /auth/ctrl/ShowApp - DeleteApp → POST /auth/ctrl/DeleteApp Following existing prototype patterns while adding type safety, retry logic, and comprehensive error handling. Ready for Phase 2 AppInstance APIs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a71f35163c
commit
9a06c608b2
32 changed files with 14733 additions and 7 deletions
214
sdk/client/apps.go
Normal file
214
sdk/client/apps.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller
|
||||
// ABOUTME: Provides typed methods for creating, querying, and deleting applications
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrResourceNotFound indicates the requested resource was not found
|
||||
ErrResourceNotFound = fmt.Errorf("resource not found")
|
||||
)
|
||||
|
||||
// CreateApp creates a new application in the specified region
|
||||
// Maps to POST /auth/ctrl/CreateApp
|
||||
func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp"
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateApp failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateApp")
|
||||
}
|
||||
|
||||
c.logf("CreateApp: %s/%s version %s created successfully",
|
||||
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShowApp retrieves a single application by key and region
|
||||
// Maps to POST /auth/ctrl/ShowApp
|
||||
func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
|
||||
|
||||
filter := AppFilter{
|
||||
AppKey: appKey,
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return App{}, fmt.Errorf("ShowApp failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
|
||||
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return App{}, c.handleErrorResponse(resp, "ShowApp")
|
||||
}
|
||||
|
||||
// Parse streaming JSON response
|
||||
var apps []App
|
||||
if err := c.parseStreamingResponse(resp, &apps); err != nil {
|
||||
return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(apps) == 0 {
|
||||
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
|
||||
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
|
||||
}
|
||||
|
||||
return apps[0], nil
|
||||
}
|
||||
|
||||
// ShowApps retrieves all applications matching the filter criteria
|
||||
// Maps to POST /auth/ctrl/ShowApp
|
||||
func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]App, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
|
||||
|
||||
filter := AppFilter{
|
||||
AppKey: appKey,
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowApps failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowApps")
|
||||
}
|
||||
|
||||
var apps []App
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return apps, nil // Return empty slice for not found
|
||||
}
|
||||
|
||||
if err := c.parseStreamingResponse(resp, &apps); err != nil {
|
||||
return nil, fmt.Errorf("ShowApps failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
c.logf("ShowApps: found %d apps matching criteria", len(apps))
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// DeleteApp removes an application from the specified region
|
||||
// Maps to POST /auth/ctrl/DeleteApp
|
||||
func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) error {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp"
|
||||
|
||||
filter := AppFilter{
|
||||
AppKey: appKey,
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteApp failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 404 is acceptable for delete operations (already deleted)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return c.handleErrorResponse(resp, "DeleteApp")
|
||||
}
|
||||
|
||||
c.logf("DeleteApp: %s/%s version %s deleted successfully",
|
||||
appKey.Organization, appKey.Name, appKey.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseStreamingResponse parses the EdgeXR streaming JSON response format
|
||||
func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error {
|
||||
var responses []Response[App]
|
||||
|
||||
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
|
||||
var response Response[App]
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
responses = append(responses, response)
|
||||
return nil
|
||||
})
|
||||
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
|
||||
// Extract data from responses
|
||||
var apps []App
|
||||
var messages []string
|
||||
|
||||
for _, response := range responses {
|
||||
if response.HasData() {
|
||||
apps = append(apps, response.Data)
|
||||
}
|
||||
if response.IsMessage() {
|
||||
messages = append(messages, response.Data.GetMessage())
|
||||
}
|
||||
}
|
||||
|
||||
// If we have error messages, return them
|
||||
if len(messages) > 0 {
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Messages: messages,
|
||||
}
|
||||
}
|
||||
|
||||
// Set result based on type
|
||||
switch v := result.(type) {
|
||||
case *[]App:
|
||||
*v = apps
|
||||
default:
|
||||
return fmt.Errorf("unsupported result type: %T", result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTransport creates an HTTP transport with current client settings
|
||||
func (c *Client) getTransport() *sdkhttp.Transport {
|
||||
return sdkhttp.NewTransport(
|
||||
sdkhttp.RetryOptions{
|
||||
MaxRetries: c.RetryOpts.MaxRetries,
|
||||
InitialDelay: c.RetryOpts.InitialDelay,
|
||||
MaxDelay: c.RetryOpts.MaxDelay,
|
||||
Multiplier: c.RetryOpts.Multiplier,
|
||||
RetryableHTTPStatusCodes: c.RetryOpts.RetryableHTTPStatusCodes,
|
||||
},
|
||||
c.AuthProvider,
|
||||
c.Logger,
|
||||
)
|
||||
}
|
||||
|
||||
// handleErrorResponse creates an appropriate error from HTTP error response
|
||||
func (c *Client) handleErrorResponse(resp *http.Response, operation string) error {
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Messages: []string{fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode)},
|
||||
}
|
||||
}
|
||||
319
sdk/client/apps_test.go
Normal file
319
sdk/client/apps_test.go
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// ABOUTME: Unit tests for App management APIs using httptest mock server
|
||||
// ABOUTME: Tests create, show, list, and delete operations with error conditions
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *NewAppInput
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful creation",
|
||||
input: &NewAppInput{
|
||||
Region: "us-west",
|
||||
App: App{
|
||||
Key: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"message": "success"}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
input: &NewAppInput{
|
||||
Region: "us-west",
|
||||
App: App{
|
||||
Key: AppKey{
|
||||
Organization: "",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 400,
|
||||
mockResponse: `{"message": "organization is required"}`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/CreateApp", r.URL.Path)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client
|
||||
client := NewClient(server.URL,
|
||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
||||
)
|
||||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
err := client.CreateApp(ctx, tt.input)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appKey AppKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
expectNotFound bool
|
||||
}{
|
||||
{
|
||||
name: "successful show",
|
||||
appKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testapp", "version": "1.0.0"}, "deployment": "kubernetes"}}
|
||||
`,
|
||||
expectError: false,
|
||||
expectNotFound: false,
|
||||
},
|
||||
{
|
||||
name: "app not found",
|
||||
appKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "nonexistent",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
mockResponse: "",
|
||||
expectError: true,
|
||||
expectNotFound: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client
|
||||
client := NewClient(server.URL,
|
||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
)
|
||||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
app, err := client.ShowApp(ctx, tt.appKey, tt.region)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
if tt.expectNotFound {
|
||||
assert.Contains(t, err.Error(), "resource not found")
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.appKey.Organization, app.Key.Organization)
|
||||
assert.Equal(t, tt.appKey.Name, app.Key.Name)
|
||||
assert.Equal(t, tt.appKey.Version, app.Key.Version)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowApps(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
|
||||
|
||||
// Verify request body
|
||||
var filter AppFilter
|
||||
err := json.NewDecoder(r.Body).Decode(&filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testorg", filter.AppKey.Organization)
|
||||
assert.Equal(t, "us-west", filter.Region)
|
||||
|
||||
// Return multiple apps
|
||||
response := `{"data": {"key": {"organization": "testorg", "name": "app1", "version": "1.0.0"}, "deployment": "kubernetes"}}
|
||||
{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}}
|
||||
`
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
apps, err := client.ShowApps(ctx, AppKey{Organization: "testorg"}, "us-west")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, apps, 2)
|
||||
assert.Equal(t, "app1", apps[0].Key.Name)
|
||||
assert.Equal(t, "app2", apps[1].Key.Name)
|
||||
}
|
||||
|
||||
func TestDeleteApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appKey AppKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful deletion",
|
||||
appKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "already deleted (404 ok)",
|
||||
appKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
appKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 500,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/DeleteApp", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.DeleteApp(ctx, tt.appKey, tt.region)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOptions(t *testing.T) {
|
||||
t.Run("with auth provider", func(t *testing.T) {
|
||||
authProvider := NewStaticTokenProvider("test-token")
|
||||
client := NewClient("https://example.com",
|
||||
WithAuthProvider(authProvider),
|
||||
)
|
||||
|
||||
assert.Equal(t, authProvider, client.AuthProvider)
|
||||
})
|
||||
|
||||
t.Run("with custom HTTP client", func(t *testing.T) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
client := NewClient("https://example.com",
|
||||
WithHTTPClient(httpClient),
|
||||
)
|
||||
|
||||
assert.Equal(t, httpClient, client.HTTPClient)
|
||||
})
|
||||
|
||||
t.Run("with retry options", func(t *testing.T) {
|
||||
retryOpts := RetryOptions{MaxRetries: 5}
|
||||
client := NewClient("https://example.com",
|
||||
WithRetryOptions(retryOpts),
|
||||
)
|
||||
|
||||
assert.Equal(t, 5, client.RetryOpts.MaxRetries)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIError(t *testing.T) {
|
||||
err := &APIError{
|
||||
StatusCode: 400,
|
||||
Messages: []string{"validation failed", "name is required"},
|
||||
}
|
||||
|
||||
assert.Equal(t, "validation failed", err.Error())
|
||||
assert.Equal(t, 400, err.StatusCode)
|
||||
assert.Len(t, err.Messages, 2)
|
||||
}
|
||||
|
||||
// Helper function to create a test server that handles streaming JSON responses
|
||||
func createStreamingJSONServer(responses []string, statusCode int) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(statusCode)
|
||||
for _, response := range responses {
|
||||
w.Write([]byte(response + "\n"))
|
||||
}
|
||||
}))
|
||||
}
|
||||
46
sdk/client/auth.go
Normal file
46
sdk/client/auth.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// ABOUTME: Authentication providers for EdgeXR Master Controller API
|
||||
// ABOUTME: Supports Bearer token authentication with pluggable provider interface
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AuthProvider interface for attaching authentication to requests
|
||||
type AuthProvider interface {
|
||||
// Attach adds authentication headers to the request
|
||||
Attach(ctx context.Context, req *http.Request) error
|
||||
}
|
||||
|
||||
// StaticTokenProvider implements Bearer token authentication with a fixed token
|
||||
type StaticTokenProvider struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
// NewStaticTokenProvider creates a new static token provider
|
||||
func NewStaticTokenProvider(token string) *StaticTokenProvider {
|
||||
return &StaticTokenProvider{Token: token}
|
||||
}
|
||||
|
||||
// Attach adds the Bearer token to the request Authorization header
|
||||
func (s *StaticTokenProvider) Attach(ctx context.Context, req *http.Request) error {
|
||||
if s.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.Token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NoAuthProvider implements no authentication (for testing or public endpoints)
|
||||
type NoAuthProvider struct{}
|
||||
|
||||
// NewNoAuthProvider creates a new no-auth provider
|
||||
func NewNoAuthProvider() *NoAuthProvider {
|
||||
return &NoAuthProvider{}
|
||||
}
|
||||
|
||||
// Attach does nothing (no authentication)
|
||||
func (n *NoAuthProvider) Attach(ctx context.Context, req *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
105
sdk/client/client.go
Normal file
105
sdk/client/client.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth
|
||||
// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client represents the EdgeXR Master Controller SDK client
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
AuthProvider AuthProvider
|
||||
RetryOpts RetryOptions
|
||||
Logger Logger
|
||||
}
|
||||
|
||||
// RetryOptions configures retry behavior for API calls
|
||||
type RetryOptions struct {
|
||||
MaxRetries int
|
||||
InitialDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
Multiplier float64
|
||||
RetryableHTTPStatusCodes []int
|
||||
}
|
||||
|
||||
// Logger interface for optional logging
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// DefaultRetryOptions returns sensible default retry configuration
|
||||
func DefaultRetryOptions() RetryOptions {
|
||||
return RetryOptions{
|
||||
MaxRetries: 3,
|
||||
InitialDelay: 1 * time.Second,
|
||||
MaxDelay: 30 * time.Second,
|
||||
Multiplier: 2.0,
|
||||
RetryableHTTPStatusCodes: []int{
|
||||
http.StatusRequestTimeout,
|
||||
http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError,
|
||||
http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Option represents a configuration option for the client
|
||||
type Option func(*Client)
|
||||
|
||||
// WithHTTPClient sets a custom HTTP client
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(c *Client) {
|
||||
c.HTTPClient = client
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthProvider sets the authentication provider
|
||||
func WithAuthProvider(auth AuthProvider) Option {
|
||||
return func(c *Client) {
|
||||
c.AuthProvider = auth
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryOptions sets retry configuration
|
||||
func WithRetryOptions(opts RetryOptions) Option {
|
||||
return func(c *Client) {
|
||||
c.RetryOpts = opts
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a logger for debugging
|
||||
func WithLogger(logger Logger) Option {
|
||||
return func(c *Client) {
|
||||
c.Logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient creates a new EdgeXR SDK client
|
||||
func NewClient(baseURL string, options ...Option) *Client {
|
||||
client := &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
AuthProvider: NewNoAuthProvider(),
|
||||
RetryOpts: DefaultRetryOptions(),
|
||||
}
|
||||
|
||||
for _, opt := range options {
|
||||
opt(client)
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (c *Client) logf(format string, v ...interface{}) {
|
||||
if c.Logger != nil {
|
||||
c.Logger.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
221
sdk/client/types.go
Normal file
221
sdk/client/types.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
// ABOUTME: Core type definitions for EdgeXR Master Controller SDK
|
||||
// ABOUTME: These types are based on the swagger API specification and existing client patterns
|
||||
|
||||
package client
|
||||
|
||||
import "time"
|
||||
|
||||
// Message interface for types that can provide error messages
|
||||
type Message interface {
|
||||
GetMessage() string
|
||||
}
|
||||
|
||||
// Base message type for API responses
|
||||
type msg struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (m msg) GetMessage() string {
|
||||
return m.Message
|
||||
}
|
||||
|
||||
// AppKey uniquely identifies an application
|
||||
type AppKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// CloudletKey uniquely identifies a cloudlet
|
||||
type CloudletKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AppInstanceKey uniquely identifies an application instance
|
||||
type AppInstanceKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
CloudletKey CloudletKey `json:"cloudlet_key"`
|
||||
}
|
||||
|
||||
// Flavor defines resource allocation for instances
|
||||
type Flavor struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SecurityRule defines network access rules
|
||||
type SecurityRule struct {
|
||||
PortRangeMax int `json:"port_range_max"`
|
||||
PortRangeMin int `json:"port_range_min"`
|
||||
Protocol string `json:"protocol"`
|
||||
RemoteCIDR string `json:"remote_cidr"`
|
||||
}
|
||||
|
||||
// App represents an application definition
|
||||
type App struct {
|
||||
msg `json:",inline"`
|
||||
Key AppKey `json:"key"`
|
||||
Deployment string `json:"deployment,omitempty"`
|
||||
ImageType string `json:"image_type,omitempty"`
|
||||
ImagePath string `json:"image_path,omitempty"`
|
||||
AllowServerless bool `json:"allow_serverless,omitempty"`
|
||||
DefaultFlavor Flavor `json:"defaultFlavor,omitempty"`
|
||||
ServerlessConfig interface{} `json:"serverless_config,omitempty"`
|
||||
DeploymentGenerator string `json:"deployment_generator,omitempty"`
|
||||
DeploymentManifest string `json:"deployment_manifest,omitempty"`
|
||||
RequiredOutboundConnections []SecurityRule `json:"required_outbound_connections"`
|
||||
}
|
||||
|
||||
// AppInstance represents a deployed application instance
|
||||
type AppInstance struct {
|
||||
msg `json:",inline"`
|
||||
Key AppInstanceKey `json:"key"`
|
||||
AppKey AppKey `json:"app_key,omitempty"`
|
||||
Flavor Flavor `json:"flavor,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
PowerState string `json:"power_state,omitempty"`
|
||||
}
|
||||
|
||||
// Cloudlet represents edge infrastructure
|
||||
type Cloudlet struct {
|
||||
msg `json:",inline"`
|
||||
Key CloudletKey `json:"key"`
|
||||
Location Location `json:"location"`
|
||||
IpSupport string `json:"ip_support,omitempty"`
|
||||
NumDynamicIps int32 `json:"num_dynamic_ips,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Flavor Flavor `json:"flavor,omitempty"`
|
||||
PhysicalName string `json:"physical_name,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
NotifySrvAddr string `json:"notify_srv_addr,omitempty"`
|
||||
}
|
||||
|
||||
// Location represents geographical coordinates
|
||||
type Location struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
// Input types for API operations
|
||||
|
||||
// NewAppInput represents input for creating an application
|
||||
type NewAppInput struct {
|
||||
Region string `json:"region"`
|
||||
App App `json:"app"`
|
||||
}
|
||||
|
||||
// NewAppInstanceInput represents input for creating an app instance
|
||||
type NewAppInstanceInput struct {
|
||||
Region string `json:"region"`
|
||||
AppInst AppInstance `json:"appinst"`
|
||||
}
|
||||
|
||||
// NewCloudletInput represents input for creating a cloudlet
|
||||
type NewCloudletInput struct {
|
||||
Region string `json:"region"`
|
||||
Cloudlet Cloudlet `json:"cloudlet"`
|
||||
}
|
||||
|
||||
// Response wrapper types
|
||||
|
||||
// Response wraps a single API response
|
||||
type Response[T Message] struct {
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
func (res *Response[T]) HasData() bool {
|
||||
return !res.IsMessage()
|
||||
}
|
||||
|
||||
func (res *Response[T]) IsMessage() bool {
|
||||
return res.Data.GetMessage() != ""
|
||||
}
|
||||
|
||||
// Responses wraps multiple API responses with metadata
|
||||
type Responses[T Message] struct {
|
||||
Responses []Response[T] `json:"responses,omitempty"`
|
||||
StatusCode int `json:"-"`
|
||||
}
|
||||
|
||||
func (r *Responses[T]) GetData() []T {
|
||||
var data []T
|
||||
for _, v := range r.Responses {
|
||||
if v.HasData() {
|
||||
data = append(data, v.Data)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (r *Responses[T]) GetMessages() []string {
|
||||
var messages []string
|
||||
for _, v := range r.Responses {
|
||||
if v.IsMessage() {
|
||||
messages = append(messages, v.Data.GetMessage())
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func (r *Responses[T]) IsSuccessful() bool {
|
||||
return r.StatusCode >= 200 && r.StatusCode < 400
|
||||
}
|
||||
|
||||
func (r *Responses[T]) Error() error {
|
||||
if r.IsSuccessful() {
|
||||
return nil
|
||||
}
|
||||
return &APIError{
|
||||
StatusCode: r.StatusCode,
|
||||
Messages: r.GetMessages(),
|
||||
}
|
||||
}
|
||||
|
||||
// APIError represents an API error with details
|
||||
type APIError struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Messages []string `json:"messages,omitempty"`
|
||||
Body []byte `json:"-"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if len(e.Messages) > 0 {
|
||||
return e.Messages[0]
|
||||
}
|
||||
return "API error"
|
||||
}
|
||||
|
||||
// Filter types for querying
|
||||
|
||||
// AppFilter represents filters for app queries
|
||||
type AppFilter struct {
|
||||
AppKey AppKey `json:"app"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// AppInstanceFilter represents filters for app instance queries
|
||||
type AppInstanceFilter struct {
|
||||
AppInstanceKey AppInstanceKey `json:"appinst"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// CloudletFilter represents filters for cloudlet queries
|
||||
type CloudletFilter struct {
|
||||
CloudletKey CloudletKey `json:"cloudlet"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// CloudletManifest represents cloudlet deployment manifest
|
||||
type CloudletManifest struct {
|
||||
Manifest string `json:"manifest"`
|
||||
LastModified time.Time `json:"last_modified,omitempty"`
|
||||
}
|
||||
|
||||
// CloudletResourceUsage represents cloudlet resource utilization
|
||||
type CloudletResourceUsage struct {
|
||||
CloudletKey CloudletKey `json:"cloudlet_key"`
|
||||
Region string `json:"region"`
|
||||
Usage map[string]interface{} `json:"usage"`
|
||||
}
|
||||
119
sdk/examples/deploy_app.go
Normal file
119
sdk/examples/deploy_app.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// ABOUTME: Example demonstrating EdgeXR SDK usage for app deployment workflow
|
||||
// ABOUTME: Shows app creation, querying, and cleanup using the typed SDK APIs
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Configure SDK client
|
||||
baseURL := getEnvOrDefault("EDGEXR_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live/api/v1")
|
||||
token := getEnvOrDefault("EDGEXR_TOKEN", "")
|
||||
|
||||
if token == "" {
|
||||
log.Fatal("EDGEXR_TOKEN environment variable is required")
|
||||
}
|
||||
|
||||
// Create SDK client with authentication and logging
|
||||
client := client.NewClient(baseURL,
|
||||
client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
client.WithAuthProvider(client.NewStaticTokenProvider(token)),
|
||||
client.WithLogger(log.Default()),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Example application to deploy
|
||||
app := &client.NewAppInput{
|
||||
Region: "us-west",
|
||||
App: client.App{
|
||||
Key: client.AppKey{
|
||||
Organization: "myorg",
|
||||
Name: "my-edge-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
ImageType: "ImageTypeDocker",
|
||||
ImagePath: "nginx:latest",
|
||||
DefaultFlavor: client.Flavor{Name: "m4.small"},
|
||||
},
|
||||
}
|
||||
|
||||
// Demonstrate app lifecycle
|
||||
if err := demonstrateAppLifecycle(ctx, client, app); err != nil {
|
||||
log.Fatalf("App lifecycle demonstration failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ SDK example completed successfully!")
|
||||
}
|
||||
|
||||
func demonstrateAppLifecycle(ctx context.Context, c *client.Client, input *client.NewAppInput) error {
|
||||
appKey := input.App.Key
|
||||
region := input.Region
|
||||
|
||||
fmt.Printf("🚀 Demonstrating EdgeXR SDK with app: %s/%s v%s\n",
|
||||
appKey.Organization, appKey.Name, appKey.Version)
|
||||
|
||||
// Step 1: Create the application
|
||||
fmt.Println("\n1. Creating application...")
|
||||
if err := c.CreateApp(ctx, input); err != nil {
|
||||
return fmt.Errorf("failed to create app: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ App created: %s/%s v%s\n", appKey.Organization, appKey.Name, appKey.Version)
|
||||
|
||||
// Step 2: Query the application
|
||||
fmt.Println("\n2. Querying application...")
|
||||
app, err := c.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to show app: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ App found: %s/%s v%s (deployment: %s)\n",
|
||||
app.Key.Organization, app.Key.Name, app.Key.Version, app.Deployment)
|
||||
|
||||
// Step 3: List applications in the organization
|
||||
fmt.Println("\n3. Listing applications...")
|
||||
filter := client.AppKey{Organization: appKey.Organization}
|
||||
apps, err := c.ShowApps(ctx, filter, region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list apps: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ Found %d applications in organization '%s'\n", len(apps), appKey.Organization)
|
||||
|
||||
// Step 4: Clean up - delete the application
|
||||
fmt.Println("\n4. Cleaning up...")
|
||||
if err := c.DeleteApp(ctx, appKey, region); err != nil {
|
||||
return fmt.Errorf("failed to delete app: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ App deleted: %s/%s v%s\n", appKey.Organization, appKey.Name, appKey.Version)
|
||||
|
||||
// Step 5: Verify deletion
|
||||
fmt.Println("\n5. Verifying deletion...")
|
||||
_, err = c.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
if fmt.Sprintf("%v", err) == client.ErrResourceNotFound.Error() {
|
||||
fmt.Printf("✅ App successfully deleted (not found)\n")
|
||||
} else {
|
||||
return fmt.Errorf("unexpected error verifying deletion: %w", err)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("app still exists after deletion")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
218
sdk/internal/http/transport.go
Normal file
218
sdk/internal/http/transport.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// ABOUTME: HTTP transport layer with retry logic and request/response handling
|
||||
// ABOUTME: Provides resilient HTTP communication with context support and error wrapping
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
)
|
||||
|
||||
// Transport wraps HTTP operations with retry logic and error handling
|
||||
type Transport struct {
|
||||
client *retryablehttp.Client
|
||||
authProvider AuthProvider
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// AuthProvider interface for attaching authentication
|
||||
type AuthProvider interface {
|
||||
Attach(ctx context.Context, req *http.Request) error
|
||||
}
|
||||
|
||||
// Logger interface for request/response logging
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// RetryOptions configures retry behavior
|
||||
type RetryOptions struct {
|
||||
MaxRetries int
|
||||
InitialDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
Multiplier float64
|
||||
RetryableHTTPStatusCodes []int
|
||||
}
|
||||
|
||||
// NewTransport creates a new HTTP transport with retry capabilities
|
||||
func NewTransport(opts RetryOptions, auth AuthProvider, logger Logger) *Transport {
|
||||
client := retryablehttp.NewClient()
|
||||
|
||||
// Configure retry policy
|
||||
client.RetryMax = opts.MaxRetries
|
||||
client.RetryWaitMin = opts.InitialDelay
|
||||
client.RetryWaitMax = opts.MaxDelay
|
||||
|
||||
// Custom retry policy that considers both network errors and HTTP status codes
|
||||
client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
|
||||
// Default retry for network errors
|
||||
if err != nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check if status code is retryable
|
||||
if resp != nil {
|
||||
for _, code := range opts.RetryableHTTPStatusCodes {
|
||||
if resp.StatusCode == code {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Custom backoff with jitter
|
||||
client.Backoff = func(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
|
||||
mult := math.Pow(opts.Multiplier, float64(attemptNum))
|
||||
sleep := time.Duration(mult) * min
|
||||
if sleep > max {
|
||||
sleep = max
|
||||
}
|
||||
// Add jitter
|
||||
jitter := time.Duration(rand.Float64() * float64(sleep) * 0.1)
|
||||
return sleep + jitter
|
||||
}
|
||||
|
||||
// Disable default logging if no logger provided
|
||||
if logger == nil {
|
||||
client.Logger = nil
|
||||
}
|
||||
|
||||
return &Transport{
|
||||
client: client,
|
||||
authProvider: auth,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Call executes an HTTP request with retry logic and returns typed response
|
||||
func (t *Transport) Call(ctx context.Context, method, url string, body interface{}) (*http.Response, error) {
|
||||
var reqBody io.Reader
|
||||
|
||||
// Marshal request body if provided
|
||||
if body != nil {
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(jsonData)
|
||||
}
|
||||
|
||||
// Create retryable request
|
||||
req, err := retryablehttp.NewRequestWithContext(ctx, method, url, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
// Add authentication
|
||||
if t.authProvider != nil {
|
||||
if err := t.authProvider.Attach(ctx, req.Request); err != nil {
|
||||
return nil, fmt.Errorf("failed to attach auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log request
|
||||
if t.logger != nil {
|
||||
t.logger.Printf("HTTP %s %s", method, url)
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP request failed: %w", err)
|
||||
}
|
||||
|
||||
// Log response
|
||||
if t.logger != nil {
|
||||
t.logger.Printf("HTTP %s %s -> %d", method, url, resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// CallJSON executes a request and unmarshals the response into a typed result
|
||||
func (t *Transport) CallJSON(ctx context.Context, method, url string, body interface{}, result interface{}) (*http.Response, error) {
|
||||
resp, err := t.Call(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// For error responses, don't try to unmarshal into result type
|
||||
if resp.StatusCode >= 400 {
|
||||
return resp, &HTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Status: resp.Status,
|
||||
Body: respBody,
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal successful response
|
||||
if result != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return resp, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// HTTPError represents an HTTP error response
|
||||
type HTTPError struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Status string `json:"status"`
|
||||
Body []byte `json:"-"`
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
if len(e.Body) > 0 {
|
||||
return fmt.Sprintf("HTTP %d %s: %s", e.StatusCode, e.Status, string(e.Body))
|
||||
}
|
||||
return fmt.Sprintf("HTTP %d %s", e.StatusCode, e.Status)
|
||||
}
|
||||
|
||||
// IsRetryable returns true if the error indicates a retryable condition
|
||||
func (e *HTTPError) IsRetryable() bool {
|
||||
return e.StatusCode >= 500 || e.StatusCode == 429 || e.StatusCode == 408
|
||||
}
|
||||
|
||||
// ParseJSONLines parses streaming JSON response line by line
|
||||
func ParseJSONLines(body io.Reader, callback func([]byte) error) error {
|
||||
decoder := json.NewDecoder(body)
|
||||
|
||||
for {
|
||||
var raw json.RawMessage
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("failed to decode JSON line: %w", err)
|
||||
}
|
||||
|
||||
if err := callback(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue