Implements comprehensive SDK support for three core services across four programming languages (JavaScript/TypeScript, Python, Go, Rust). ## New SDKs ### Wallet SDK - Key management (create, import, export) - Transaction signing - Message signing and verification - Balance and UTXO queries - Stealth address support ### RPC SDK - Block and transaction queries - Chain state information - Fee estimation - Mempool information - WebSocket subscriptions for real-time updates ### Storage SDK - Content upload and download - Pinning operations - CAR file support - Directory management - Gateway URL generation ## Shared Infrastructure - JSON Schema definitions for all 11 services - Common type definitions (Address, Amount, UTXO, etc.) - Unified error handling patterns - Builder patterns for configuration ## Package Updates - JavaScript: Updated to @synor/sdk with module exports - Python: Updated to synor-sdk with websockets dependency - Go: Added gorilla/websocket dependency - Rust: Added base64, urlencoding, multipart support ## Fixes - Fixed Tensor Default trait implementation - Fixed ProcessorType enum casing
673 lines
16 KiB
Go
673 lines
16 KiB
Go
// Package storage provides a Go SDK for Synor Storage operations.
|
|
//
|
|
// Decentralized storage, pinning, and content retrieval on the Synor network.
|
|
//
|
|
// Example:
|
|
//
|
|
// client := storage.NewClient("your-api-key")
|
|
// result, err := client.Upload(ctx, []byte("Hello, World!"), nil)
|
|
// fmt.Println("CID:", result.CID)
|
|
//
|
|
// // Get gateway URL
|
|
// gateway := client.GetGatewayURL(result.CID, "")
|
|
// fmt.Println("URL:", gateway.URL)
|
|
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// Version of the SDK.
|
|
const Version = "0.1.0"
|
|
|
|
// Default endpoints.
|
|
const (
|
|
DefaultEndpoint = "https://storage.synor.cc/api/v1"
|
|
DefaultGateway = "https://gateway.synor.cc"
|
|
)
|
|
|
|
// PinStatus represents the status of a pin.
|
|
type PinStatus string
|
|
|
|
const (
|
|
Queued PinStatus = "queued"
|
|
Pinning PinStatus = "pinning"
|
|
Pinned PinStatus = "pinned"
|
|
Failed PinStatus = "failed"
|
|
Unpinned PinStatus = "unpinned"
|
|
)
|
|
|
|
// HashAlgorithm represents a hash algorithm.
|
|
type HashAlgorithm string
|
|
|
|
const (
|
|
SHA256 HashAlgorithm = "sha2-256"
|
|
BLAKE3 HashAlgorithm = "blake3"
|
|
)
|
|
|
|
// EntryType represents a directory entry type.
|
|
type EntryType string
|
|
|
|
const (
|
|
FileType EntryType = "file"
|
|
DirectoryType EntryType = "directory"
|
|
)
|
|
|
|
// MatchType represents a pin matching type.
|
|
type MatchType string
|
|
|
|
const (
|
|
Exact MatchType = "exact"
|
|
IExact MatchType = "iexact"
|
|
Partial MatchType = "partial"
|
|
IPartial MatchType = "ipartial"
|
|
)
|
|
|
|
// Config holds client configuration.
|
|
type Config struct {
|
|
APIKey string
|
|
Endpoint string
|
|
Gateway string
|
|
PinningService string
|
|
ChunkSize int
|
|
Timeout time.Duration
|
|
Debug bool
|
|
}
|
|
|
|
// DefaultConfig returns a default configuration.
|
|
func DefaultConfig(apiKey string) Config {
|
|
return Config{
|
|
APIKey: apiKey,
|
|
Endpoint: DefaultEndpoint,
|
|
Gateway: DefaultGateway,
|
|
ChunkSize: 262144, // 256KB
|
|
Timeout: 30 * time.Second,
|
|
Debug: false,
|
|
}
|
|
}
|
|
|
|
// Client is the Synor Storage client.
|
|
type Client struct {
|
|
config Config
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewClient creates a new client with the given API key.
|
|
func NewClient(apiKey string) *Client {
|
|
return NewClientWithConfig(DefaultConfig(apiKey))
|
|
}
|
|
|
|
// NewClientWithConfig creates a new client with custom configuration.
|
|
func NewClientWithConfig(config Config) *Client {
|
|
return &Client{
|
|
config: config,
|
|
httpClient: &http.Client{
|
|
Timeout: config.Timeout,
|
|
},
|
|
}
|
|
}
|
|
|
|
// UploadOptions contains options for uploading content.
|
|
type UploadOptions struct {
|
|
Pin bool
|
|
WrapWithDirectory bool
|
|
CIDVersion int
|
|
HashAlgorithm HashAlgorithm
|
|
}
|
|
|
|
// UploadResponse contains the result of an upload.
|
|
type UploadResponse struct {
|
|
CID string `json:"cid"`
|
|
Size int64 `json:"size"`
|
|
Name string `json:"name,omitempty"`
|
|
Hash string `json:"hash,omitempty"`
|
|
}
|
|
|
|
// DownloadOptions contains options for downloading content.
|
|
type DownloadOptions struct {
|
|
Offset int64
|
|
Length int64
|
|
}
|
|
|
|
// Pin represents pin information.
|
|
type Pin struct {
|
|
CID string `json:"cid"`
|
|
Status PinStatus `json:"status"`
|
|
Name string `json:"name,omitempty"`
|
|
Size int64 `json:"size,omitempty"`
|
|
CreatedAt int64 `json:"createdAt,omitempty"`
|
|
ExpiresAt int64 `json:"expiresAt,omitempty"`
|
|
Delegates []string `json:"delegates,omitempty"`
|
|
}
|
|
|
|
// PinRequest contains options for pinning content.
|
|
type PinRequest struct {
|
|
CID string `json:"cid"`
|
|
Name string `json:"name,omitempty"`
|
|
Duration int64 `json:"duration,omitempty"`
|
|
Origins []string `json:"origins,omitempty"`
|
|
}
|
|
|
|
// ListPinsOptions contains options for listing pins.
|
|
type ListPinsOptions struct {
|
|
Status []PinStatus
|
|
Match MatchType
|
|
Name string
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
// ListPinsResponse contains the result of listing pins.
|
|
type ListPinsResponse struct {
|
|
Pins []Pin `json:"pins"`
|
|
Total int `json:"total"`
|
|
HasMore bool `json:"hasMore"`
|
|
}
|
|
|
|
// GatewayURL represents a gateway URL.
|
|
type GatewayURL struct {
|
|
URL string
|
|
CID string
|
|
Path string
|
|
}
|
|
|
|
// CarBlock represents a CAR block.
|
|
type CarBlock struct {
|
|
CID string `json:"cid"`
|
|
Data string `json:"data"`
|
|
Size int64 `json:"size,omitempty"`
|
|
}
|
|
|
|
// CarFile represents a CAR file.
|
|
type CarFile struct {
|
|
Version int `json:"version"`
|
|
Roots []string `json:"roots"`
|
|
Blocks []CarBlock `json:"blocks,omitempty"`
|
|
Size int64 `json:"size,omitempty"`
|
|
}
|
|
|
|
// FileEntry represents a file entry for directory creation.
|
|
type FileEntry struct {
|
|
Name string
|
|
Content []byte
|
|
CID string
|
|
}
|
|
|
|
// DirectoryEntry represents a directory entry.
|
|
type DirectoryEntry struct {
|
|
Name string `json:"name"`
|
|
CID string `json:"cid"`
|
|
Size int64 `json:"size,omitempty"`
|
|
Type EntryType `json:"type"`
|
|
}
|
|
|
|
// ImportCarResponse contains the result of importing a CAR file.
|
|
type ImportCarResponse struct {
|
|
Roots []string `json:"roots"`
|
|
BlocksImported int `json:"blocksImported"`
|
|
}
|
|
|
|
// StorageStats represents storage statistics.
|
|
type StorageStats struct {
|
|
TotalSize int64 `json:"totalSize"`
|
|
PinCount int `json:"pinCount"`
|
|
Bandwidth struct {
|
|
Upload int64 `json:"upload"`
|
|
Download int64 `json:"download"`
|
|
} `json:"bandwidth,omitempty"`
|
|
}
|
|
|
|
// Upload uploads content to storage.
|
|
func (c *Client) Upload(ctx context.Context, data []byte, opts *UploadOptions) (*UploadResponse, error) {
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
|
|
part, err := writer.CreateFormFile("file", "file")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create form file: %w", err)
|
|
}
|
|
|
|
if _, err := part.Write(data); err != nil {
|
|
return nil, fmt.Errorf("failed to write data: %w", err)
|
|
}
|
|
|
|
if err := writer.Close(); err != nil {
|
|
return nil, fmt.Errorf("failed to close writer: %w", err)
|
|
}
|
|
|
|
params := url.Values{}
|
|
if opts != nil {
|
|
if opts.Pin {
|
|
params.Set("pin", "true")
|
|
}
|
|
if opts.WrapWithDirectory {
|
|
params.Set("wrapWithDirectory", "true")
|
|
}
|
|
if opts.CIDVersion != 0 {
|
|
params.Set("cidVersion", fmt.Sprintf("%d", opts.CIDVersion))
|
|
}
|
|
if opts.HashAlgorithm != "" {
|
|
params.Set("hashAlgorithm", string(opts.HashAlgorithm))
|
|
}
|
|
}
|
|
|
|
path := "/upload"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
|
|
reqURL := c.config.Endpoint + path
|
|
req, err := http.NewRequestWithContext(ctx, "POST", reqURL, &buf)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
if c.config.Debug {
|
|
fmt.Printf("[SynorStorage] POST %s\n", path)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
|
|
var result UploadResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// Download downloads content by CID.
|
|
func (c *Client) Download(ctx context.Context, cid string, opts *DownloadOptions) ([]byte, error) {
|
|
params := url.Values{}
|
|
if opts != nil {
|
|
if opts.Offset > 0 {
|
|
params.Set("offset", fmt.Sprintf("%d", opts.Offset))
|
|
}
|
|
if opts.Length > 0 {
|
|
params.Set("length", fmt.Sprintf("%d", opts.Length))
|
|
}
|
|
}
|
|
|
|
path := fmt.Sprintf("/content/%s", cid)
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
|
|
reqURL := c.config.Endpoint + path
|
|
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// DownloadReader returns a reader for downloading content.
|
|
func (c *Client) DownloadReader(ctx context.Context, cid string, opts *DownloadOptions) (io.ReadCloser, error) {
|
|
params := url.Values{}
|
|
if opts != nil {
|
|
if opts.Offset > 0 {
|
|
params.Set("offset", fmt.Sprintf("%d", opts.Offset))
|
|
}
|
|
if opts.Length > 0 {
|
|
params.Set("length", fmt.Sprintf("%d", opts.Length))
|
|
}
|
|
}
|
|
|
|
path := fmt.Sprintf("/content/%s/stream", cid)
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
|
|
reqURL := c.config.Endpoint + path
|
|
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
defer resp.Body.Close()
|
|
return nil, c.parseError(resp)
|
|
}
|
|
|
|
return resp.Body, nil
|
|
}
|
|
|
|
// Pin pins content by CID.
|
|
func (c *Client) Pin(ctx context.Context, req PinRequest) (*Pin, error) {
|
|
var result Pin
|
|
if err := c.request(ctx, "POST", "/pins", req, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// Unpin unpins content by CID.
|
|
func (c *Client) Unpin(ctx context.Context, cid string) error {
|
|
return c.request(ctx, "DELETE", "/pins/"+cid, nil, nil)
|
|
}
|
|
|
|
// GetPinStatus gets the pin status for a CID.
|
|
func (c *Client) GetPinStatus(ctx context.Context, cid string) (*Pin, error) {
|
|
var result Pin
|
|
if err := c.request(ctx, "GET", "/pins/"+cid, nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ListPins lists pins.
|
|
func (c *Client) ListPins(ctx context.Context, opts *ListPinsOptions) (*ListPinsResponse, error) {
|
|
path := "/pins"
|
|
if opts != nil {
|
|
params := url.Values{}
|
|
if len(opts.Status) > 0 {
|
|
var statuses []string
|
|
for _, s := range opts.Status {
|
|
statuses = append(statuses, string(s))
|
|
}
|
|
params.Set("status", joinStrings(statuses, ","))
|
|
}
|
|
if opts.Match != "" {
|
|
params.Set("match", string(opts.Match))
|
|
}
|
|
if opts.Name != "" {
|
|
params.Set("name", opts.Name)
|
|
}
|
|
if opts.Limit > 0 {
|
|
params.Set("limit", fmt.Sprintf("%d", opts.Limit))
|
|
}
|
|
if opts.Offset > 0 {
|
|
params.Set("offset", fmt.Sprintf("%d", opts.Offset))
|
|
}
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
}
|
|
|
|
var result ListPinsResponse
|
|
if err := c.request(ctx, "GET", path, nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// GetGatewayURL returns the gateway URL for content.
|
|
func (c *Client) GetGatewayURL(cid string, path string) GatewayURL {
|
|
fullPath := "/" + cid
|
|
if path != "" {
|
|
fullPath += "/" + path
|
|
}
|
|
return GatewayURL{
|
|
URL: c.config.Gateway + "/ipfs" + fullPath,
|
|
CID: cid,
|
|
Path: path,
|
|
}
|
|
}
|
|
|
|
// CreateCar creates a CAR file from files.
|
|
func (c *Client) CreateCar(ctx context.Context, files []FileEntry) (*CarFile, error) {
|
|
type fileData struct {
|
|
Name string `json:"name"`
|
|
Content string `json:"content,omitempty"`
|
|
CID string `json:"cid,omitempty"`
|
|
}
|
|
|
|
var entries []fileData
|
|
for _, f := range files {
|
|
entry := fileData{Name: f.Name}
|
|
if len(f.Content) > 0 {
|
|
entry.Content = base64.StdEncoding.EncodeToString(f.Content)
|
|
}
|
|
if f.CID != "" {
|
|
entry.CID = f.CID
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
|
|
var result CarFile
|
|
if err := c.request(ctx, "POST", "/car/create", map[string]interface{}{"files": entries}, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ImportCar imports a CAR file.
|
|
func (c *Client) ImportCar(ctx context.Context, carData []byte, pin bool) (*ImportCarResponse, error) {
|
|
encoded := base64.StdEncoding.EncodeToString(carData)
|
|
body := map[string]interface{}{
|
|
"car": encoded,
|
|
"pin": pin,
|
|
}
|
|
|
|
var result ImportCarResponse
|
|
if err := c.request(ctx, "POST", "/car/import", body, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ExportCar exports content as a CAR file.
|
|
func (c *Client) ExportCar(ctx context.Context, cid string) ([]byte, error) {
|
|
reqURL := c.config.Endpoint + "/car/export/" + cid
|
|
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// CreateDirectory creates a directory from files.
|
|
func (c *Client) CreateDirectory(ctx context.Context, files []FileEntry) (*UploadResponse, error) {
|
|
type fileData struct {
|
|
Name string `json:"name"`
|
|
Content string `json:"content,omitempty"`
|
|
CID string `json:"cid,omitempty"`
|
|
}
|
|
|
|
var entries []fileData
|
|
for _, f := range files {
|
|
entry := fileData{Name: f.Name}
|
|
if len(f.Content) > 0 {
|
|
entry.Content = base64.StdEncoding.EncodeToString(f.Content)
|
|
}
|
|
if f.CID != "" {
|
|
entry.CID = f.CID
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
|
|
var result UploadResponse
|
|
if err := c.request(ctx, "POST", "/directory", map[string]interface{}{"files": entries}, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ListDirectory lists directory contents.
|
|
func (c *Client) ListDirectory(ctx context.Context, cid string, path string) ([]DirectoryEntry, error) {
|
|
apiPath := fmt.Sprintf("/directory/%s", cid)
|
|
if path != "" {
|
|
apiPath += "?path=" + url.QueryEscape(path)
|
|
}
|
|
|
|
var resp struct {
|
|
Entries []DirectoryEntry `json:"entries"`
|
|
}
|
|
if err := c.request(ctx, "GET", apiPath, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Entries, nil
|
|
}
|
|
|
|
// GetStats gets storage statistics.
|
|
func (c *Client) GetStats(ctx context.Context) (*StorageStats, error) {
|
|
var result StorageStats
|
|
if err := c.request(ctx, "GET", "/stats", nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// Exists checks if content exists.
|
|
func (c *Client) Exists(ctx context.Context, cid string) (bool, error) {
|
|
reqURL := c.config.Endpoint + "/content/" + cid
|
|
req, err := http.NewRequestWithContext(ctx, "HEAD", reqURL, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
return resp.StatusCode == 200, nil
|
|
}
|
|
|
|
// GetMetadata gets content metadata.
|
|
func (c *Client) GetMetadata(ctx context.Context, cid string) (map[string]interface{}, error) {
|
|
var result map[string]interface{}
|
|
if err := c.request(ctx, "GET", "/content/"+cid+"/metadata", nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c *Client) request(ctx context.Context, method, path string, body interface{}, result interface{}) error {
|
|
reqURL := c.config.Endpoint + path
|
|
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
bodyBytes, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
bodyReader = bytes.NewReader(bodyBytes)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
if c.config.Debug {
|
|
fmt.Printf("[SynorStorage] %s %s\n", method, path)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return c.parseError(resp)
|
|
}
|
|
|
|
if result != nil && resp.StatusCode != 204 {
|
|
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
|
return fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) parseError(resp *http.Response) error {
|
|
var errResp struct {
|
|
Message string `json:"message"`
|
|
Code string `json:"code"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&errResp)
|
|
return &StorageError{
|
|
Message: errResp.Message,
|
|
StatusCode: resp.StatusCode,
|
|
Code: errResp.Code,
|
|
}
|
|
}
|
|
|
|
func joinStrings(strs []string, sep string) string {
|
|
if len(strs) == 0 {
|
|
return ""
|
|
}
|
|
result := strs[0]
|
|
for i := 1; i < len(strs); i++ {
|
|
result += sep + strs[i]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// StorageError represents an API error.
|
|
type StorageError struct {
|
|
Message string
|
|
StatusCode int
|
|
Code string
|
|
}
|
|
|
|
func (e *StorageError) Error() string {
|
|
return fmt.Sprintf("storage: %s (status %d)", e.Message, e.StatusCode)
|
|
}
|